Merge upstream main into feat/500-api-key-ip-whitelist

This commit is contained in:
RWDai
2026-05-20 10:26:56 +08:00
501 changed files with 47013 additions and 3667 deletions
@@ -1,6 +1,9 @@
use crate::async_task::CancelVideoTaskError;
use crate::control::GatewayControlDecision;
use crate::control::GatewayPublicRequestContext;
use crate::image_capabilities::{
openai_image_gateway_max_generation_count, openai_image_gateway_max_generation_count_for_model,
};
use crate::{AppState, GatewayError};
use aether_data_contracts::repository::video_tasks::{
StoredVideoTask, VideoTaskQueryFilter, VideoTaskStatus,
@@ -18,9 +21,6 @@ const AI_PUBLIC_METHOD_NOT_ALLOWED_DETAIL: &str = "Method not allowed";
const AI_PUBLIC_UNAUTHORIZED_DETAIL: &str = "Unauthorized";
const OPENAI_IMAGE_PROMPT_DETAIL: &str = "图片生成/编辑请求缺少 prompt";
const OPENAI_IMAGE_EDIT_INPUT_DETAIL: &str = "图片编辑请求至少需要 1 张输入图片";
const OPENAI_IMAGE_VARIATION_INPUT_DETAIL: &str = "图片变体请求需要 image 文件";
const OPENAI_IMAGE_N_DETAIL: &str = "当前 Codex 图片反代仅支持 n=1";
const OPENAI_IMAGE_STREAM_VARIATION_DETAIL: &str = "图片变体接口当前仅支持同步响应";
const OPENAI_IMAGE_PARTIAL_IMAGES_DETAIL: &str =
"partial_images 仅支持 0-3,且必须配合 stream=true";
const OPENAI_IMAGE_STYLE_DETAIL: &str = "当前 Codex 图片反代暂不支持 style 参数";
@@ -57,7 +57,6 @@ const OPENAI_RERANK_STREAM_UNSUPPORTED_DETAIL: &str = "Rerank requests do not su
enum OpenAiImageOperation {
Generate,
Edit,
Variation,
}
impl OpenAiImageOperation {
@@ -65,7 +64,6 @@ impl OpenAiImageOperation {
match path {
"/v1/images/generations" => Some(Self::Generate),
"/v1/images/edits" => Some(Self::Edit),
"/v1/images/variations" => Some(Self::Variation),
_ => None,
}
}
@@ -202,7 +200,7 @@ fn maybe_build_local_openai_request_validation_response(
if decision.route_kind.as_deref() != Some("image")
|| !matches!(
request_context.request_path.as_str(),
"/v1/images/generations" | "/v1/images/edits" | "/v1/images/variations"
"/v1/images/generations" | "/v1/images/edits"
)
{
return None;
@@ -240,19 +238,13 @@ fn maybe_build_local_openai_request_validation_response(
OPENAI_IMAGE_EDIT_INPUT_DETAIL,
));
}
OpenAiImageOperation::Variation if validation.image_count == 0 => {
return Some(build_ai_public_error_response(
http::StatusCode::BAD_REQUEST,
OPENAI_IMAGE_VARIATION_INPUT_DETAIL,
));
}
_ => {}
}
if validation.n.is_some_and(|value| value != 1) {
if let Some(detail) = validate_openai_image_n(&validation) {
return Some(build_ai_public_error_response(
http::StatusCode::BAD_REQUEST,
OPENAI_IMAGE_N_DETAIL,
detail,
));
}
@@ -272,15 +264,6 @@ fn maybe_build_local_openai_request_validation_response(
));
}
if validation.stream {
if operation == OpenAiImageOperation::Variation {
return Some(build_ai_public_error_response(
http::StatusCode::BAD_REQUEST,
OPENAI_IMAGE_STREAM_VARIATION_DETAIL,
));
}
}
if validation
.response_format
.as_deref()
@@ -360,6 +343,23 @@ fn maybe_build_local_openai_request_validation_response(
None
}
fn openai_image_n_detail(max_generation_count: u64) -> String {
if max_generation_count >= openai_image_gateway_max_generation_count() {
format!("当前图片反代仅支持 n=1..{max_generation_count}")
} else {
format!("当前图片模型仅支持 n=1..{max_generation_count}")
}
}
fn validate_openai_image_n(validation: &OpenAiImageValidationInput) -> Option<String> {
let max_generation_count =
openai_image_gateway_max_generation_count_for_model(validation.model.as_deref());
validation
.n
.is_some_and(|value| value == 0 || value > max_generation_count)
.then(|| openai_image_n_detail(max_generation_count))
}
fn validate_openai_embedding_request(
content_type: Option<&str>,
request_body: &Bytes,
@@ -535,7 +535,6 @@ fn parse_openai_image_validation_input(
OpenAiImageOperation::Generate | OpenAiImageOperation::Edit => {
OPENAI_IMAGE_PROMPT_DETAIL
}
OpenAiImageOperation::Variation => OPENAI_IMAGE_VARIATION_INPUT_DETAIL,
});
}
@@ -1260,7 +1259,8 @@ fn estimate_text_tokens(text: &str) -> u64 {
#[cfg(test)]
mod tests {
use super::{
estimate_claude_count_tokens, parse_openai_image_validation_input, OpenAiImageOperation,
estimate_claude_count_tokens, parse_openai_image_validation_input, validate_openai_image_n,
OpenAiImageOperation,
};
use axum::body::Bytes;
use serde_json::json;
@@ -1344,4 +1344,31 @@ mod tests {
assert_eq!(validation.prompt.as_deref(), Some("edit this image"));
assert_eq!(validation.image_count, 1);
}
#[test]
fn image_validation_restricts_multi_image_count_to_grok_models() {
let openai_body = Bytes::from_static(br#"{"model":"gpt-image-2","prompt":"draw","n":2}"#);
let openai_validation = parse_openai_image_validation_input(
OpenAiImageOperation::Generate,
Some("application/json"),
&openai_body,
)
.expect("valid image payload should parse");
assert_eq!(
validate_openai_image_n(&openai_validation).as_deref(),
Some("当前图片模型仅支持 n=1..1")
);
let grok_body =
Bytes::from_static(br#"{"model":"grok-imagine-image-lite","prompt":"draw","n":4}"#);
let grok_validation = parse_openai_image_validation_input(
OpenAiImageOperation::Generate,
Some("application/json"),
&grok_body,
)
.expect("valid grok image payload should parse");
assert!(validate_openai_image_n(&grok_validation).is_none());
}
}
@@ -32,6 +32,7 @@ struct AdminAnnouncementCreateRequest {
kind: String,
priority: Option<i32>,
is_pinned: Option<bool>,
requires_ack: Option<bool>,
start_time: Option<String>,
end_time: Option<String>,
}
@@ -45,6 +46,7 @@ struct AdminAnnouncementUpdateRequest {
priority: Option<i32>,
is_active: Option<bool>,
is_pinned: Option<bool>,
requires_ack: Option<bool>,
start_time: Option<String>,
end_time: Option<String>,
}
@@ -168,6 +170,7 @@ fn build_create_record(
kind: payload.kind,
priority: payload.priority.unwrap_or(0),
is_pinned: payload.is_pinned.unwrap_or(false),
requires_ack: payload.requires_ack.unwrap_or(false),
author_id: operator_id,
start_time_unix_secs: parse_optional_rfc3339_unix_secs(
payload.start_time.as_deref(),
@@ -194,6 +197,7 @@ fn build_update_record(
priority: payload.priority,
is_active: payload.is_active,
is_pinned: payload.is_pinned,
requires_ack: payload.requires_ack,
start_time_unix_secs: parse_optional_rfc3339_unix_secs(
payload.start_time.as_deref(),
"start_time",
@@ -78,6 +78,7 @@ pub(super) fn build_public_announcement_payload(
"priority": announcement.priority,
"is_active": announcement.is_active,
"is_pinned": announcement.is_pinned,
"requires_ack": announcement.requires_ack,
"author": {
"id": announcement.author_id,
"username": announcement.author_username,
@@ -13,7 +13,7 @@ use super::super::{build_unhandled_public_support_response, resolve_authenticate
use super::announcements_shared::{
announcements_bad_request_response, announcements_internal_detail,
announcements_internal_error_response, announcements_not_found_response,
read_status_announcement_id_from_path,
build_public_announcement_payload, read_status_announcement_id_from_path,
};
#[derive(Debug, serde::Deserialize)]
@@ -75,6 +75,37 @@ pub(crate) async fn maybe_build_local_announcement_user_response(
};
Some(Json(json!({ "unread_count": unread_count })).into_response())
}
Some("required_unread")
if request_context.request_method == http::Method::GET
&& matches!(
request_context.request_path.as_str(),
"/api/announcements/users/me/required-unread"
| "/api/announcements/users/me/required-unread/"
) =>
{
let items = match state
.list_required_unread_active_announcements(&auth.user.id, now_unix_secs, 20)
.await
{
Ok(value) => value,
Err(err) => {
return Some(announcements_internal_error_response(
announcements_internal_detail(err),
))
}
};
let payload_items = items
.iter()
.map(build_public_announcement_payload)
.collect::<Vec<_>>();
Some(
Json(json!({
"items": payload_items,
"total": payload_items.len(),
}))
.into_response(),
)
}
Some("read_all")
if request_context.request_method == http::Method::POST
&& matches!(
@@ -26,6 +26,18 @@ pub(crate) async fn build_auth_registration_settings_payload(
let turnstile_site_key_config = state
.read_system_config_json_value("turnstile_site_key")
.await?;
let privacy_enabled_config = state
.read_system_config_json_value("registration_privacy_policy_enabled")
.await?;
let privacy_format_config = state
.read_system_config_json_value("registration_privacy_policy_format")
.await?;
let privacy_content_config = state
.read_system_config_json_value("registration_privacy_policy_content")
.await?;
let privacy_version_config = state
.read_system_config_json_value("registration_privacy_policy_version")
.await?;
let email_configured = smtp_host
.as_ref()
@@ -48,6 +60,15 @@ pub(crate) async fn build_auth_registration_settings_payload(
};
let turnstile_enabled = system_config_bool(turnstile_enabled_config.as_ref(), false);
let turnstile_site_key = system_config_string(turnstile_site_key_config.as_ref());
let privacy_policy_enabled = system_config_bool(privacy_enabled_config.as_ref(), false);
let privacy_policy_format = match system_config_string(privacy_format_config.as_ref()) {
Some(value) if matches!(value.as_str(), "markdown" | "html") => value,
_ => "markdown".to_string(),
};
let privacy_policy_content =
system_config_string(privacy_content_config.as_ref()).unwrap_or_default();
let privacy_policy_version =
system_config_string(privacy_version_config.as_ref()).unwrap_or_else(|| "1".to_string());
Ok(json!({
"enable_registration": enable_registration,
@@ -57,6 +78,12 @@ pub(crate) async fn build_auth_registration_settings_payload(
"turnstile_enabled": turnstile_enabled,
"turnstile_site_key": turnstile_site_key,
"turnstile_required_actions": ["send_verification_code", "register"],
"privacy_policy": {
"enabled": privacy_policy_enabled,
"format": privacy_policy_format,
"content": privacy_policy_content,
"version": privacy_policy_version,
},
}))
}
@@ -18,6 +18,9 @@ struct AuthRegisterRequest {
username: String,
password: String,
turnstile_token: Option<String>,
invite_code: Option<String>,
privacy_policy_accepted: Option<bool>,
privacy_policy_version: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -131,6 +134,26 @@ pub(crate) fn validate_auth_register_password(password: &str, policy: &str) -> R
Ok(())
}
struct RegistrationPrivacyPolicySettings {
enabled: bool,
version: String,
}
async fn read_registration_privacy_policy_settings(
state: &AppState,
) -> Result<RegistrationPrivacyPolicySettings, GatewayError> {
let enabled = state
.read_system_config_json_value("registration_privacy_policy_enabled")
.await?;
let version = state
.read_system_config_json_value("registration_privacy_policy_version")
.await?;
Ok(RegistrationPrivacyPolicySettings {
enabled: system_config_bool(enabled.as_ref(), false),
version: system_config_string(version.as_ref()).unwrap_or_else(|| "1".to_string()),
})
}
pub(crate) async fn auth_password_policy_level(state: &AppState) -> Result<String, GatewayError> {
let config = state
.read_system_config_json_value("password_policy_level")
@@ -388,6 +411,31 @@ pub(super) async fn handle_auth_register(
if !enable_registration {
return build_auth_error_response(http::StatusCode::FORBIDDEN, "系统暂不开放注册", false);
}
let privacy_policy = match read_registration_privacy_policy_settings(state).await {
Ok(value) => value,
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("auth settings lookup failed: {err:?}"),
false,
);
}
};
if privacy_policy.enabled {
let accepted = payload.privacy_policy_accepted.unwrap_or(false);
let accepted_version = payload
.privacy_policy_version
.as_deref()
.map(str::trim)
.unwrap_or_default();
if !accepted || accepted_version != privacy_policy.version {
return build_auth_error_response(
http::StatusCode::BAD_REQUEST,
"请先阅读并同意当前版本的隐私政策",
false,
);
}
}
if let Err(response) = verify_auth_turnstile(
state,
@@ -555,6 +603,63 @@ pub(super) async fn handle_auth_register(
false,
);
}
if privacy_policy.enabled {
match state
.record_user_privacy_policy_acceptance(&user.id, &privacy_policy.version)
.await
{
Ok(true) => {}
Ok(false) => {
let _ = state.delete_local_auth_user(&user.id).await;
return build_auth_error_response(
http::StatusCode::SERVICE_UNAVAILABLE,
AUTH_REGISTRATION_STORAGE_UNAVAILABLE_DETAIL,
false,
);
}
Err(err) => {
let _ = state.delete_local_auth_user(&user.id).await;
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("auth privacy policy acceptance failed: {err:?}"),
false,
);
}
}
}
let invite_code = payload
.invite_code
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
if invite_code.is_some() {
let source = json!({
"channel": "registration",
"ip": cf_connecting_ip,
"user_agent": headers
.get(http::header::USER_AGENT)
.and_then(|value| value.to_str().ok()),
});
if let Err(err) = state
.bind_referral_invite_after_registration(
&user.id,
user.email_verified,
invite_code,
Some(source),
)
.await
{
let _ = state.delete_local_auth_user(&user.id).await;
let (status, detail) = match err {
GatewayError::Client { status, message } => (status, message),
other => (
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("auth referral binding failed: {other:?}"),
),
};
return build_auth_error_response(status, detail, false);
}
}
if require_verification {
if let Some(email) = email.as_deref() {
@@ -14,11 +14,11 @@ use super::{
const INSTALL_SESSION_TTL_SECS: u64 = 15 * 60;
const INSTALL_SESSION_KEY_PREFIX: &str = "install:session:";
const PROXY_INSTALL_SESSION_KEY_PREFIX: &str = "proxy-install:session:";
const PROXY_INSTALL_UNIX_SCRIPT_URL: &str =
"https://raw.githubusercontent.com/fawney19/Aether/main/apps/aether-proxy/install.sh";
const PROXY_INSTALL_POWERSHELL_SCRIPT_URL: &str =
"https://raw.githubusercontent.com/fawney19/Aether/main/apps/aether-proxy/install.ps1";
const TUNNEL_INSTALL_SESSION_KEY_PREFIX: &str = "tunnel-install:session:";
const TUNNEL_INSTALL_UNIX_SCRIPT_URL: &str =
"https://raw.githubusercontent.com/fawney19/Aether/main/apps/aether-tunnel/install.sh";
const TUNNEL_INSTALL_POWERSHELL_SCRIPT_URL: &str =
"https://raw.githubusercontent.com/fawney19/Aether/main/apps/aether-tunnel/install.ps1";
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
@@ -55,7 +55,7 @@ struct StoredInstallSession {
}
#[derive(Debug, Serialize, Deserialize)]
struct StoredProxyInstallSession {
struct StoredTunnelInstallSession {
aether_url: String,
management_token: String,
node_name: String,
@@ -91,9 +91,10 @@ fn install_code_from_path(request_path: &str) -> Option<(String, bool)> {
(!code.is_empty()).then(|| (code.to_string(), is_powershell))
}
fn proxy_install_code_from_path(request_path: &str) -> Option<(String, bool)> {
fn tunnel_install_code_from_path(request_path: &str) -> Option<(String, bool)> {
let raw = request_path
.strip_prefix("/install-proxy/")?
.strip_prefix("/install-tunnel/")
.or_else(|| request_path.strip_prefix("/install-proxy/"))?
.trim()
.trim_matches('/');
if raw.is_empty() || raw.contains('/') {
@@ -108,8 +109,8 @@ fn install_session_runtime_key(code: &str) -> String {
format!("{INSTALL_SESSION_KEY_PREFIX}{code}")
}
fn proxy_install_session_runtime_key(code: &str) -> String {
format!("{PROXY_INSTALL_SESSION_KEY_PREFIX}{code}")
fn tunnel_install_session_runtime_key(code: &str) -> String {
format!("{TUNNEL_INSTALL_SESSION_KEY_PREFIX}{code}")
}
fn generate_install_code() -> String {
@@ -164,42 +165,42 @@ fn powershell_single_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "''"))
}
fn build_proxy_unix_script(session: &StoredProxyInstallSession) -> String {
fn build_tunnel_unix_script(session: &StoredTunnelInstallSession) -> String {
format!(
r###"#!/bin/sh
set -eu
export AETHER_PROXY_AETHER_URL={aether_url}
export AETHER_PROXY_MANAGEMENT_TOKEN={management_token}
export AETHER_PROXY_NODE_NAME={node_name}
export AETHER_TUNNEL_AETHER_URL={aether_url}
export AETHER_TUNNEL_MANAGEMENT_TOKEN={management_token}
export AETHER_TUNNEL_NODE_NAME={node_name}
if command -v curl >/dev/null 2>&1; then
curl -fsSL {script_url} | sh
elif command -v wget >/dev/null 2>&1; then
wget -qO- {script_url} | sh
else
printf '%s\n' "[Aether Proxy] 需要 curl 或 wget 下载安装脚本" >&2
printf '%s\n' "[Aether Tunnel] 需要 curl 或 wget 下载安装脚本" >&2
exit 1
fi
"###,
aether_url = shell_single_quote(&session.aether_url),
management_token = shell_single_quote(&session.management_token),
node_name = shell_single_quote(&session.node_name),
script_url = shell_single_quote(PROXY_INSTALL_UNIX_SCRIPT_URL),
script_url = shell_single_quote(TUNNEL_INSTALL_UNIX_SCRIPT_URL),
)
}
fn build_proxy_powershell_script(session: &StoredProxyInstallSession) -> String {
fn build_tunnel_powershell_script(session: &StoredTunnelInstallSession) -> String {
format!(
r###"$ErrorActionPreference = 'Stop'
$env:AETHER_PROXY_AETHER_URL = {aether_url}
$env:AETHER_PROXY_MANAGEMENT_TOKEN = {management_token}
$env:AETHER_PROXY_NODE_NAME = {node_name}
$env:AETHER_TUNNEL_AETHER_URL = {aether_url}
$env:AETHER_TUNNEL_MANAGEMENT_TOKEN = {management_token}
$env:AETHER_TUNNEL_NODE_NAME = {node_name}
irm {script_url} | iex
"###,
aether_url = powershell_single_quote(&session.aether_url),
management_token = powershell_single_quote(&session.management_token),
node_name = powershell_single_quote(&session.node_name),
script_url = powershell_single_quote(PROXY_INSTALL_POWERSHELL_SCRIPT_URL),
script_url = powershell_single_quote(TUNNEL_INSTALL_POWERSHELL_SCRIPT_URL),
)
}
@@ -659,7 +660,7 @@ pub(crate) async fn build_proxy_node_install_session_response(
) -> Response<Body> {
let code = generate_install_code();
let expires_at_unix_secs = unix_secs_now().saturating_add(INSTALL_SESSION_TTL_SECS);
let session = StoredProxyInstallSession {
let session = StoredTunnelInstallSession {
aether_url: base_url_from_request(headers, request_context),
management_token,
node_name,
@@ -670,14 +671,14 @@ pub(crate) async fn build_proxy_node_install_session_response(
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("proxy install session serialize failed: {err:?}"),
format!("tunnel install session serialize failed: {err:?}"),
false,
)
}
};
if let Err(err) = state
.runtime_kv_setex(
&proxy_install_session_runtime_key(&code),
&tunnel_install_session_runtime_key(&code),
&serialized,
INSTALL_SESSION_TTL_SECS,
)
@@ -685,7 +686,7 @@ pub(crate) async fn build_proxy_node_install_session_response(
{
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("proxy install session create failed: {err:?}"),
format!("tunnel install session create failed: {err:?}"),
false,
);
}
@@ -697,8 +698,8 @@ pub(crate) async fn build_proxy_node_install_session_response(
"expires_in_seconds": INSTALL_SESSION_TTL_SECS,
"node_name": session.node_name,
"aether_url": session.aether_url,
"unix_command": format!("curl -fsSL {base_url}/install-proxy/{code} | sh"),
"powershell_command": format!("irm {base_url}/install-proxy/{code}.ps1 | iex"),
"unix_command": format!("curl -fsSL {base_url}/install-tunnel/{code} | sh"),
"powershell_command": format!("irm {base_url}/install-tunnel/{code}.ps1 | iex"),
}))
.into_response()
}
@@ -711,8 +712,10 @@ pub(super) async fn maybe_build_local_install_response(
if decision.route_family.as_deref() != Some("install") {
return None;
}
if request_context.request_path.starts_with("/install-proxy/") {
return Some(maybe_build_local_proxy_install_response(state, request_context).await);
if request_context.request_path.starts_with("/install-tunnel/")
|| request_context.request_path.starts_with("/install-proxy/")
{
return Some(maybe_build_local_tunnel_install_response(state, request_context).await);
}
let Some((code, wants_powershell)) = install_code_from_path(&request_context.request_path)
else {
@@ -789,45 +792,45 @@ pub(super) async fn maybe_build_local_install_response(
Some(response)
}
async fn maybe_build_local_proxy_install_response(
async fn maybe_build_local_tunnel_install_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
) -> Response<Body> {
let Some((code, wants_powershell)) =
proxy_install_code_from_path(&request_context.request_path)
tunnel_install_code_from_path(&request_context.request_path)
else {
return build_auth_error_response(
http::StatusCode::NOT_FOUND,
"proxy install code 不存在或已失效",
"tunnel install code 不存在或已失效",
false,
);
};
let raw = match state
.runtime_kv_getdel(&proxy_install_session_runtime_key(&code))
.runtime_kv_getdel(&tunnel_install_session_runtime_key(&code))
.await
{
Ok(Some(value)) => value,
Ok(None) => {
return build_auth_error_response(
http::StatusCode::NOT_FOUND,
"proxy install code 不存在、已过期或已使用",
"tunnel install code 不存在、已过期或已使用",
false,
)
}
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("proxy install session lookup failed: {err:?}"),
format!("tunnel install session lookup failed: {err:?}"),
false,
)
}
};
let session = match serde_json::from_str::<StoredProxyInstallSession>(&raw) {
let session = match serde_json::from_str::<StoredTunnelInstallSession>(&raw) {
Ok(value) => value,
Err(_) => {
return build_auth_error_response(
http::StatusCode::BAD_REQUEST,
"proxy install code 数据无效",
"tunnel install code 数据无效",
false,
)
}
@@ -835,14 +838,14 @@ async fn maybe_build_local_proxy_install_response(
if session.expires_at_unix_secs <= unix_secs_now() {
return build_auth_error_response(
http::StatusCode::NOT_FOUND,
"proxy install code 已过期",
"tunnel install code 已过期",
false,
);
}
let body = if wants_powershell {
build_proxy_powershell_script(&session)
build_tunnel_powershell_script(&session)
} else {
build_proxy_unix_script(&session)
build_tunnel_unix_script(&session)
};
let content_type = if wants_powershell {
"text/plain; charset=utf-8"
@@ -885,8 +888,8 @@ mod tests {
}
}
fn test_proxy_session() -> StoredProxyInstallSession {
StoredProxyInstallSession {
fn test_tunnel_session() -> StoredTunnelInstallSession {
StoredTunnelInstallSession {
aether_url: "https://aether.example".to_string(),
management_token: "ae-test-token".to_string(),
node_name: "jp-proxy-01".to_string(),
@@ -895,41 +898,45 @@ mod tests {
}
#[test]
fn proxy_install_path_accepts_shell_and_powershell_codes() {
fn tunnel_install_path_accepts_shell_and_powershell_codes() {
assert_eq!(
proxy_install_code_from_path("/install-proxy/abc123"),
tunnel_install_code_from_path("/install-tunnel/abc123"),
Some(("abc123".to_string(), false))
);
assert_eq!(
proxy_install_code_from_path("/install-proxy/abc123.ps1"),
tunnel_install_code_from_path("/install-tunnel/abc123.ps1"),
Some(("abc123".to_string(), true))
);
assert_eq!(proxy_install_code_from_path("/install-proxy/a/b"), None);
assert_eq!(
tunnel_install_code_from_path("/install-proxy/abc123"),
Some(("abc123".to_string(), false))
);
assert_eq!(tunnel_install_code_from_path("/install-tunnel/a/b"), None);
}
#[test]
fn proxy_unix_script_exports_session_values_and_reuses_proxy_installer() {
let script = build_proxy_unix_script(&test_proxy_session());
fn tunnel_unix_script_exports_session_values_and_reuses_tunnel_installer() {
let script = build_tunnel_unix_script(&test_tunnel_session());
assert!(script.contains("export AETHER_PROXY_AETHER_URL='https://aether.example'"));
assert!(script.contains("export AETHER_PROXY_MANAGEMENT_TOKEN='ae-test-token'"));
assert!(script.contains("export AETHER_PROXY_NODE_NAME='jp-proxy-01'"));
assert!(script.contains("export AETHER_TUNNEL_AETHER_URL='https://aether.example'"));
assert!(script.contains("export AETHER_TUNNEL_MANAGEMENT_TOKEN='ae-test-token'"));
assert!(script.contains("export AETHER_TUNNEL_NODE_NAME='jp-proxy-01'"));
assert!(script.contains(
"https://raw.githubusercontent.com/fawney19/Aether/main/apps/aether-proxy/install.sh"
"https://raw.githubusercontent.com/fawney19/Aether/main/apps/aether-tunnel/install.sh"
));
assert!(!script.contains("aether-rust-pioneer"));
assert!(!script.contains("[[servers]]"));
}
#[test]
fn proxy_powershell_script_exports_session_values_and_reuses_proxy_installer() {
let script = build_proxy_powershell_script(&test_proxy_session());
fn tunnel_powershell_script_exports_session_values_and_reuses_tunnel_installer() {
let script = build_tunnel_powershell_script(&test_tunnel_session());
assert!(script.contains("$env:AETHER_PROXY_AETHER_URL = 'https://aether.example'"));
assert!(script.contains("$env:AETHER_PROXY_MANAGEMENT_TOKEN = 'ae-test-token'"));
assert!(script.contains("$env:AETHER_PROXY_NODE_NAME = 'jp-proxy-01'"));
assert!(script.contains("$env:AETHER_TUNNEL_AETHER_URL = 'https://aether.example'"));
assert!(script.contains("$env:AETHER_TUNNEL_MANAGEMENT_TOKEN = 'ae-test-token'"));
assert!(script.contains("$env:AETHER_TUNNEL_NODE_NAME = 'jp-proxy-01'"));
assert!(script.contains(
"https://raw.githubusercontent.com/fawney19/Aether/main/apps/aether-proxy/install.ps1"
"https://raw.githubusercontent.com/fawney19/Aether/main/apps/aether-tunnel/install.ps1"
));
assert!(!script.contains("aether-rust-pioneer"));
assert!(!script.contains("[[servers]]"));
@@ -3,6 +3,7 @@ use std::collections::BTreeMap;
use axum::{body::Body, http, response::Response};
use md5::{Digest, Md5};
use serde_json::json;
use tracing::warn;
use super::{payment_shared::payment_callback_payload_hash, AppState, GatewayPublicRequestContext};
@@ -373,9 +374,20 @@ pub(super) async fn handle_epay_notify(
match outcome {
Ok(Some(aether_data::repository::wallet::ProcessPaymentCallbackOutcome::Applied {
order,
order_id,
..
}))
| Ok(Some(
})) => {
if let Err(err) = state.apply_referral_rewards_for_paid_order(&order).await {
warn!(
error = ?err,
order_id = %order_id,
"failed to apply referral rewards for epay callback"
);
}
epay_plain(http::StatusCode::OK, "success")
}
Ok(Some(
aether_data::repository::wallet::ProcessPaymentCallbackOutcome::AlreadyCredited {
..
},
@@ -10,6 +10,7 @@ use super::{
build_auth_error_response, build_payment_callback_storage_unavailable_response, AppState,
GatewayPublicRequestContext,
};
use tracing::warn;
pub(super) async fn handle_payment_callback_with_wallet_repository(
state: &AppState,
@@ -109,21 +110,30 @@ pub(super) async fn handle_payment_callback_with_wallet_repository(
order_no,
wallet_id,
order,
} => build_auth_json_response(
http::StatusCode::OK,
json!({
"ok": true,
"duplicate": duplicate,
"credited": true,
"order_id": order_id,
"order_no": order_no,
"status": order.status,
"wallet_id": wallet_id,
"payment_method": payment_method,
"request_path": request_context.request_path,
}),
None,
),
} => {
if let Err(err) = state.apply_referral_rewards_for_paid_order(&order).await {
warn!(
error = ?err,
order_id = %order_id,
"failed to apply referral rewards for credited payment order"
);
}
build_auth_json_response(
http::StatusCode::OK,
json!({
"ok": true,
"duplicate": duplicate,
"credited": true,
"order_id": order_id,
"order_no": order_no,
"status": order.status,
"wallet_id": wallet_id,
"payment_method": payment_method,
"request_path": request_context.request_path,
}),
None,
)
}
}
}
@@ -169,7 +169,11 @@ pub(super) async fn maybe_build_local_test_connection_route_response(
}
let mut provider_request_body = match format_value.as_str() {
"openai:chat" | "claude:messages" => json!({
"openai:chat" => json!({
"model": model,
"messages": [{"role": "user", "content": "Health check"}],
}),
"claude:messages" => json!({
"model": model,
"messages": [{"role": "user", "content": "Health check"}],
"max_tokens": 5,
@@ -179,9 +183,6 @@ pub(super) async fn maybe_build_local_test_connection_route_response(
"role": "user",
"parts": [{"text": "Health check"}],
}],
"generationConfig": {
"maxOutputTokens": 5,
},
}),
_ => return None,
};
@@ -31,6 +31,9 @@ use user_me_catalog::*;
#[path = "user_me_preferences.rs"]
mod user_me_preferences;
use user_me_preferences::*;
#[path = "user_me_referral.rs"]
mod user_me_referral;
use user_me_referral::*;
#[path = "user_me_profile.rs"]
mod user_me_profile;
use user_me_profile::*;
@@ -0,0 +1,69 @@
use super::{
build_auth_error_response, resolve_authenticated_local_user, AppState,
GatewayPublicRequestContext,
};
use axum::{
body::Body,
http,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
pub(super) async fn handle_users_me_referral_get(
state: &AppState,
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 !state.has_referral_data_backend() {
return build_auth_error_response(
http::StatusCode::SERVICE_UNAVAILABLE,
"邀请返利数据暂不可用",
false,
);
}
let dashboard = match state.referral_dashboard(&auth.user.id).await {
Ok(Some(value)) => value,
Ok(None) => {
return build_auth_error_response(
http::StatusCode::SERVICE_UNAVAILABLE,
"邀请返利数据暂不可用",
false,
);
}
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("referral dashboard failed: {err:?}"),
false,
);
}
};
let base = headers
.get("origin")
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or_default();
let invitation_link = if base.is_empty() {
format!("/register?invite={}", dashboard.invite_code)
} else {
format!("{base}/register?invite={}", dashboard.invite_code)
};
Json(json!({
"invite_code": dashboard.invite_code,
"invitation_link": invitation_link,
"summary": {
"total_invites": dashboard.total_invites,
"effective_invites": dashboard.effective_invites,
"paid_reward_usd": dashboard.paid_reward_usd,
"pending_reward_usd": dashboard.pending_reward_usd,
"reversed_reward_usd": dashboard.reversed_reward_usd,
}
}))
.into_response()
}
@@ -15,11 +15,12 @@ use super::{
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_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_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,
@@ -211,6 +212,9 @@ pub(crate) async fn maybe_build_local_users_me_response(
Some("preferences") if request_context.request_path == "/api/users/me/preferences" => {
Some(handle_users_me_preferences_get(state, request_context, headers).await)
}
Some("referral") if request_context.request_path == "/api/users/me/referral" => {
Some(handle_users_me_referral_get(state, request_context, headers).await)
}
Some("available_models")
if request_context.request_path == "/api/users/me/available-models" =>
{
@@ -321,6 +321,75 @@ fn users_me_usage_upstream_is_stream(item: &StoredRequestUsageAudit) -> bool {
.unwrap_or(item.is_stream)
}
fn users_me_usage_metadata_string<'a>(
item: &'a StoredRequestUsageAudit,
key: &str,
) -> Option<&'a str> {
item.request_metadata
.as_ref()
.and_then(serde_json::Value::as_object)
.and_then(|metadata| metadata.get(key))
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
}
fn infer_client_family_from_user_agent(user_agent: &str) -> Option<&'static str> {
let normalized = user_agent.trim().to_ascii_lowercase();
if normalized.is_empty() {
return None;
}
if normalized.starts_with("codex_vscode") {
return Some("codex_vscode");
}
if normalized.starts_with("codex") {
return Some("codex");
}
if normalized.contains("claude-code") || normalized.contains("claude_code") {
return Some("claude_code");
}
if normalized.contains("opencode") {
return Some("opencode");
}
if normalized.contains("geminicli") || normalized.contains("gemini-cli") {
return Some("gemini_cli");
}
if normalized.starts_with("openai/js") {
return Some("openai_js_sdk");
}
None
}
fn users_me_usage_client_family(item: &StoredRequestUsageAudit) -> Option<&str> {
item.client_family
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.or_else(|| {
item.request_metadata
.as_ref()
.and_then(serde_json::Value::as_object)
.and_then(|metadata| {
metadata
.get("client_session_affinity")
.and_then(serde_json::Value::as_object)
.and_then(|affinity| affinity.get("client_family"))
.and_then(serde_json::Value::as_str)
.or_else(|| {
metadata
.get("client_family")
.and_then(serde_json::Value::as_str)
})
})
.map(str::trim)
.filter(|value| !value.is_empty())
})
.or_else(|| {
users_me_usage_metadata_string(item, "user_agent")
.and_then(infer_client_family_from_user_agent)
})
}
fn build_users_me_usage_record_payload(
item: &StoredRequestUsageAudit,
include_actual_cost: bool,
@@ -353,6 +422,11 @@ fn build_users_me_usage_record_payload(
"upstream_is_stream": upstream_is_stream,
"client_requested_stream": client_is_stream,
"client_is_stream": client_is_stream,
"client_family": users_me_usage_client_family(item),
"client_ip": users_me_usage_metadata_string(item, "client_ip"),
"user_agent": users_me_usage_metadata_string(item, "user_agent"),
"request_path": users_me_usage_metadata_string(item, "request_path"),
"request_path_and_query": users_me_usage_metadata_string(item, "request_path_and_query"),
"status": item.status,
"has_fallback": item.has_fallback(),
"created_at": unix_secs_to_rfc3339(item.created_at_unix_ms),
@@ -411,6 +485,9 @@ fn build_users_me_usage_active_payload(item: &StoredRequestUsageAudit) -> serde_
"client_requested_stream": client_is_stream,
"client_is_stream": client_is_stream,
"has_format_conversion": item.has_format_conversion,
"client_family": users_me_usage_client_family(item),
"client_ip": users_me_usage_metadata_string(item, "client_ip"),
"user_agent": users_me_usage_metadata_string(item, "user_agent"),
"target_model": item.target_model,
"has_fallback": item.has_fallback(),
});
@@ -1368,6 +1445,41 @@ mod tests {
assert_eq!(active_payload["client_is_stream"], false);
}
#[test]
fn user_usage_payload_infers_client_family_from_user_agent() {
let item = StoredRequestUsageAudit {
request_metadata: Some(json!({
"client_ip": "192.168.0.28",
"user_agent": "codex_vscode/0.131.0-alpha.9 (Windows 10.0.26200; x86_64)"
})),
..sample_usage("completed")
};
let record_payload =
build_users_me_usage_record_payload(&item, false, &BTreeMap::new(), false);
let active_payload = build_users_me_usage_active_payload(&item);
assert_eq!(record_payload["client_family"], "codex_vscode");
assert_eq!(record_payload["client_ip"], "192.168.0.28");
assert_eq!(active_payload["client_family"], "codex_vscode");
assert_eq!(active_payload["client_ip"], "192.168.0.28");
}
#[test]
fn user_usage_payload_labels_openai_js_user_agent_as_sdk() {
let item = StoredRequestUsageAudit {
request_metadata: Some(json!({
"user_agent": "OpenAI/JS 6.34.0"
})),
..sample_usage("completed")
};
let record_payload =
build_users_me_usage_record_payload(&item, false, &BTreeMap::new(), false);
assert_eq!(record_payload["client_family"], "openai_js_sdk");
}
#[test]
fn user_usage_stream_inference_falls_back_to_request_body_stream_flag() {
let item = StoredRequestUsageAudit {