feat: ProxyNode 代理节点管理系统与 OpenAI Responses API 解析增强

ProxyNode 系统:新增 aether-proxy(Rust)海外 VPS 代理组件,后端实现节点注册/心跳/
HMAC 认证/健康检测调度器/模块化集成,前端新增代理节点管理页面。ProxyConfig 支持
node_id 模式,http_client 支持 HMAC 签名代理 URL 构建与 TTL 缓存。

OpenAI CLI 解析器:适配 Responses API 格式,支持 input_tokens/output_tokens 提取、
output[].content[].text 文本解析、response.completed 流式事件 usage 嵌套结构。
This commit is contained in:
fawney19
2026-02-07 12:24:42 +08:00
parent 62f852b851
commit 1180634269
40 changed files with 4761 additions and 11 deletions

View File

@@ -0,0 +1,207 @@
use reqwest::Client;
use serde::{Deserialize, Serialize};
use tracing::{debug, error, info, warn};
use crate::config::Config;
#[derive(Debug, Serialize)]
struct RegisterRequest {
name: String,
ip: String,
port: u16,
#[serde(skip_serializing_if = "Option::is_none")]
region: Option<String>,
heartbeat_interval: u64,
}
#[derive(Debug, Deserialize)]
pub struct RegisterResponse {
pub node_id: String,
}
#[derive(Debug, Serialize)]
struct HeartbeatRequest {
node_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
active_connections: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
total_requests: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
avg_latency_ms: Option<f64>,
}
#[derive(Debug, Serialize)]
struct UnregisterRequest {
node_id: String,
}
/// Aether API client for proxy node lifecycle management.
pub struct AetherClient {
http: Client,
base_url: String,
token: String,
}
impl AetherClient {
pub fn new(config: &Config) -> Self {
let http = Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.expect("failed to create HTTP client");
Self {
http,
base_url: config.aether_url.trim_end_matches('/').to_string(),
token: config.management_token.clone(),
}
}
/// Register this node with Aether (idempotent upsert by ip:port).
///
/// Returns the stable node_id assigned by Aether.
pub async fn register(
&self,
config: &Config,
public_ip: &str,
) -> anyhow::Result<String> {
let url = format!("{}/api/admin/proxy-nodes/register", self.base_url);
let body = RegisterRequest {
name: config.node_name.clone(),
ip: public_ip.to_string(),
port: config.listen_port,
region: config.node_region.clone(),
heartbeat_interval: config.heartbeat_interval,
};
info!(
url = %url,
name = %body.name,
ip = %body.ip,
port = body.port,
"registering with Aether"
);
let resp = self
.http
.post(&url)
.header("Authorization", format!("Bearer {}", self.token))
.json(&body)
.send()
.await?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
anyhow::bail!("register failed (HTTP {}): {}", status, text);
}
let data: RegisterResponse = resp.json().await?;
info!(node_id = %data.node_id, "registered successfully");
Ok(data.node_id)
}
/// Send heartbeat to Aether.
pub async fn heartbeat(
&self,
node_id: &str,
active_connections: Option<i64>,
total_requests: Option<i64>,
avg_latency_ms: Option<f64>,
) -> anyhow::Result<()> {
let url = format!("{}/api/admin/proxy-nodes/heartbeat", self.base_url);
let body = HeartbeatRequest {
node_id: node_id.to_string(),
active_connections,
total_requests,
avg_latency_ms,
};
debug!(node_id = %node_id, "sending heartbeat");
let resp = self
.http
.post(&url)
.header("Authorization", format!("Bearer {}", self.token))
.json(&body)
.send()
.await?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
warn!(status = %status, body = %text, "heartbeat failed");
anyhow::bail!("heartbeat failed (HTTP {}): {}", status, text);
}
debug!(node_id = %node_id, "heartbeat ok");
Ok(())
}
/// Unregister this node from Aether (graceful shutdown).
pub async fn unregister(&self, node_id: &str) -> anyhow::Result<()> {
let url = format!("{}/api/admin/proxy-nodes/unregister", self.base_url);
let body = UnregisterRequest {
node_id: node_id.to_string(),
};
info!(node_id = %node_id, "unregistering from Aether");
let resp = self
.http
.post(&url)
.header("Authorization", format!("Bearer {}", self.token))
.json(&body)
.send()
.await;
match resp {
Ok(r) if r.status().is_success() => {
info!(node_id = %node_id, "unregistered successfully");
Ok(())
}
Ok(r) => {
let text = r.text().await.unwrap_or_default();
error!(body = %text, "unregister failed");
anyhow::bail!("unregister failed: {}", text);
}
Err(e) => {
// Best-effort during shutdown
error!(error = %e, "unregister request failed");
anyhow::bail!("unregister request failed: {}", e);
}
}
}
}
/// Auto-detect public IP by querying external services.
pub async fn detect_public_ip() -> anyhow::Result<String> {
let endpoints = [
"https://api.ipify.org",
"https://ifconfig.me/ip",
"https://icanhazip.com",
];
let client = Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()?;
for endpoint in &endpoints {
match client.get(*endpoint).send().await {
Ok(resp) if resp.status().is_success() => {
let ip = resp.text().await?.trim().to_string();
if !ip.is_empty() {
info!(ip = %ip, source = %endpoint, "detected public IP");
return Ok(ip);
}
}
Ok(resp) => {
debug!(endpoint = %endpoint, status = %resp.status(), "IP detection failed");
}
Err(e) => {
debug!(endpoint = %endpoint, error = %e, "IP detection failed");
}
}
}
anyhow::bail!("failed to detect public IP from any source; use --public-ip")
}

View File

@@ -0,0 +1,50 @@
use std::sync::Arc;
use tokio::sync::watch;
use tracing::{debug, warn};
use crate::registration::client::AetherClient;
/// Run periodic heartbeat task until shutdown signal.
pub async fn run(
client: Arc<AetherClient>,
node_id: Arc<String>,
interval_secs: u64,
mut shutdown_rx: watch::Receiver<bool>,
) {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(interval_secs));
// Skip the first immediate tick (registration already acts as initial heartbeat)
interval.tick().await;
let mut consecutive_failures: u32 = 0;
loop {
tokio::select! {
_ = interval.tick() => {
match client.heartbeat(&node_id, None, None, None).await {
Ok(()) => {
if consecutive_failures > 0 {
debug!(
previous_failures = consecutive_failures,
"heartbeat recovered"
);
}
consecutive_failures = 0;
}
Err(e) => {
consecutive_failures += 1;
warn!(
error = %e,
consecutive_failures,
"heartbeat failed"
);
}
}
}
_ = shutdown_rx.changed() => {
debug!("heartbeat task stopping");
break;
}
}
}
}

View File

@@ -0,0 +1,2 @@
pub mod client;
pub mod heartbeat;