mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-10 11:19:50 +08:00
Merge upstream main into feat/500-api-key-ip-whitelist
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user