feat: 引入 Rust executor/gateway sidecar 及 Python 侧双后端适配

- 新增 Rust workspace crates: aether-contracts, aether-executor, aether-gateway
- aether-executor: 支持 Unix Socket/TCP 双传输模式,处理同步/流式上游请求
- aether-gateway: 作为本地主入口代理,集成 /api/internal/gateway/resolve 认证预解析
- Python 侧新增 ExecutionPlan 契约和 RustExecutorClient,各 handler 支持
  executor_backend=rust 时将可序列化请求转发给 Rust executor 执行
- 重构 dev.sh 支持 executor/gateway 进程编排与生命周期管理
- 新增 internal gateway 路由,提供 resolve/passthrough 端点
- handler 层(chat/cli/video/endpoint_checker 等)全面适配 Rust executor 回退逻辑
- pipeline 层支持 trusted auth context 跳过重复认证
- 新增 Rust CI workflow 及对应测试用例
This commit is contained in:
fawney19
2026-03-21 12:57:09 +08:00
parent 46737d32f8
commit d735b6316f
79 changed files with 19032 additions and 522 deletions

View File

@@ -0,0 +1,27 @@
[package]
name = "aether-executor"
version = "0.1.0"
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "Rust executor scaffold for Aether request execution"
[dependencies]
aether-contracts.workspace = true
async-stream.workspace = true
axum = { version = "0.8" }
base64.workspace = true
bytes.workspace = true
clap = { version = "4", features = ["derive", "env"] }
flate2.workspace = true
futures-util.workspace = true
http.workspace = true
reqwest.workspace = true
rustls.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tokio.workspace = true
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
webpki-roots.workspace = true

View File

@@ -0,0 +1,57 @@
use std::path::PathBuf;
use aether_contracts::{ExecutionPlan, ExecutionResult, StreamFrame};
use crate::{ExecutorClientError, TransportMode};
#[derive(Debug, Clone)]
pub struct ExecutorClientConfig {
pub transport: TransportMode,
pub endpoint: Option<String>,
pub socket_path: Option<PathBuf>,
}
impl ExecutorClientConfig {
pub fn unix_socket(socket_path: impl Into<PathBuf>) -> Self {
Self {
transport: TransportMode::UnixSocketHttp,
endpoint: None,
socket_path: Some(socket_path.into()),
}
}
}
#[derive(Debug, Clone)]
pub struct ExecutorClient {
config: ExecutorClientConfig,
}
impl ExecutorClient {
pub fn new(config: ExecutorClientConfig) -> Self {
Self { config }
}
pub fn config(&self) -> &ExecutorClientConfig {
&self.config
}
pub async fn execute(
&self,
_plan: &ExecutionPlan,
) -> Result<ExecutionResult, ExecutorClientError> {
if self.config.endpoint.is_none() && self.config.socket_path.is_none() {
return Err(ExecutorClientError::MissingEndpoint);
}
Err(ExecutorClientError::Unimplemented)
}
pub async fn execute_stream(
&self,
_plan: &ExecutionPlan,
) -> Result<Vec<StreamFrame>, ExecutorClientError> {
if self.config.endpoint.is_none() && self.config.socket_path.is_none() {
return Err(ExecutorClientError::MissingEndpoint);
}
Err(ExecutorClientError::Unimplemented)
}
}

View File

@@ -0,0 +1,48 @@
use http::method::InvalidMethod;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ExecutorClientError {
#[error("executor endpoint is not configured")]
MissingEndpoint,
#[error("executor request is not implemented yet")]
Unimplemented,
#[error("failed to encode NDJSON frame: {0}")]
Encode(#[from] serde_json::Error),
}
#[derive(Debug, Error)]
pub enum ExecutorServiceError {
#[error("stream execution is not implemented yet")]
StreamUnsupported,
#[error("request body must contain json_body or body_bytes_b64")]
RequestBodyRequired,
#[error("request body base64 is invalid: {0}")]
BodyDecode(base64::DecodeError),
#[error("request content-encoding is not supported: {0}")]
UnsupportedContentEncoding(String),
#[error("proxy execution is not implemented yet")]
ProxyUnsupported,
#[error("tls profile overrides are not implemented yet")]
TlsProfileUnsupported,
#[error("tunnel delegate execution is not implemented yet")]
DelegateUnsupported,
#[error("invalid method: {0}")]
InvalidMethod(#[from] InvalidMethod),
#[error("invalid upstream header name: {0}")]
InvalidHeaderName(String),
#[error("invalid upstream header value for {0}")]
InvalidHeaderValue(String),
#[error("invalid proxy configuration: {0}")]
InvalidProxy(reqwest::Error),
#[error("failed to encode request body: {0}")]
BodyEncode(serde_json::Error),
#[error("failed to build HTTP client: {0}")]
ClientBuild(reqwest::Error),
#[error("failed to execute upstream request: {0}")]
UpstreamRequest(reqwest::Error),
#[error("hub relay request failed: {0}")]
RelayError(String),
#[error("upstream response is not valid JSON: {0}")]
InvalidJson(serde_json::Error),
}

View File

@@ -0,0 +1,12 @@
mod client;
mod error;
mod ndjson;
pub mod server;
mod service;
mod transport;
pub use client::{ExecutorClient, ExecutorClientConfig};
pub use error::{ExecutorClientError, ExecutorServiceError};
pub use ndjson::{decode_frame, encode_frame};
pub use service::{SyncExecutor, UpstreamStreamExecution};
pub use transport::TransportMode;

View File

@@ -0,0 +1,52 @@
use std::path::PathBuf;
use clap::Parser;
use tracing::info;
use aether_executor::server;
#[derive(Parser, Debug)]
#[command(name = "aether-executor", about = "Internal Rust executor for Aether")]
struct Args {
#[arg(long, env = "AETHER_EXECUTOR_TRANSPORT", default_value = "unix_socket")]
transport: String,
#[arg(long, env = "AETHER_EXECUTOR_BIND", default_value = "127.0.0.1:5219")]
bind: String,
#[arg(
long,
env = "AETHER_EXECUTOR_UNIX_SOCKET",
default_value = "/tmp/aether-executor.sock"
)]
unix_socket: PathBuf,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let _ = rustls::crypto::ring::default_provider().install_default();
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "aether_executor=info".into()),
)
.init();
let args = Args::parse();
match args.transport.trim().to_ascii_lowercase().as_str() {
"unix_socket" | "unix" | "uds" => {
info!(socket = %args.unix_socket.display(), "aether-executor started");
server::serve_unix(&args.unix_socket).await?;
}
"tcp" => {
info!(bind = %args.bind, "aether-executor started");
server::serve_tcp(&args.bind).await?;
}
other => {
return Err(format!("unsupported executor transport: {other}").into());
}
}
Ok(())
}

View File

@@ -0,0 +1,38 @@
use aether_contracts::StreamFrame;
use bytes::Bytes;
use crate::ExecutorClientError;
pub fn encode_frame(frame: &StreamFrame) -> Result<Bytes, ExecutorClientError> {
let mut raw = serde_json::to_vec(frame)?;
raw.push(b'\n');
Ok(Bytes::from(raw))
}
pub fn decode_frame(line: &[u8]) -> Result<StreamFrame, ExecutorClientError> {
Ok(serde_json::from_slice(line)?)
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use aether_contracts::{StreamFrame, StreamFramePayload, StreamFrameType};
use super::{decode_frame, encode_frame};
#[test]
fn ndjson_round_trip_preserves_frame() {
let frame = StreamFrame {
frame_type: StreamFrameType::Headers,
payload: StreamFramePayload::Headers {
status_code: 200,
headers: BTreeMap::from([("content-type".into(), "text/event-stream".into())]),
},
};
let raw = encode_frame(&frame).expect("frame should encode");
let decoded = decode_frame(raw.trim_ascii_end()).expect("frame should decode");
assert_eq!(decoded, frame);
}
}

View File

@@ -0,0 +1,187 @@
use std::convert::Infallible;
use std::path::Path;
use aether_contracts::{
ExecutionError, ExecutionErrorKind, ExecutionPhase, ExecutionPlan, ExecutionResult,
ExecutionTelemetry, StreamFrame, StreamFramePayload, StreamFrameType,
};
use async_stream::stream;
use axum::body::Body;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use base64::Engine as _;
use bytes::Bytes;
use futures_util::StreamExt;
use serde_json::json;
use crate::{encode_frame, ExecutorServiceError, SyncExecutor};
#[derive(Debug, Clone, Default)]
pub struct AppState {
executor: SyncExecutor,
}
pub fn build_router() -> Router {
Router::new()
.route("/health", get(health))
.route("/v1/execute/sync", post(execute_sync))
.route("/v1/execute/stream", post(execute_stream))
.with_state(AppState {
executor: SyncExecutor::new(),
})
}
pub async fn serve_tcp(bind: &str) -> Result<(), Box<dyn std::error::Error>> {
let listener = tokio::net::TcpListener::bind(bind).await?;
axum::serve(listener, build_router()).await?;
Ok(())
}
pub async fn serve_unix(socket_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
if let Some(parent) = socket_path.parent() {
std::fs::create_dir_all(parent)?;
}
if socket_path.exists() {
std::fs::remove_file(socket_path)?;
}
let listener = tokio::net::UnixListener::bind(socket_path)?;
axum::serve(listener, build_router()).await?;
Ok(())
}
async fn health() -> impl IntoResponse {
Json(json!({"status": "ok"}))
}
async fn execute_sync(
State(state): State<AppState>,
Json(plan): Json<ExecutionPlan>,
) -> Result<Json<ExecutionResult>, AppError> {
state
.executor
.execute_sync(plan)
.await
.map(Json)
.map_err(AppError)
}
async fn execute_stream(
State(state): State<AppState>,
Json(plan): Json<ExecutionPlan>,
) -> Result<Response, AppError> {
let execution = state
.executor
.execute_stream(plan)
.await
.map_err(AppError)?;
let status_code = execution.status_code;
let response_headers = execution.headers.clone();
let started_at = execution.started_at;
let upstream_response = execution.response;
let body_stream = stream! {
let headers_frame = StreamFrame {
frame_type: StreamFrameType::Headers,
payload: StreamFramePayload::Headers {
status_code,
headers: response_headers,
},
};
yield Ok::<Bytes, Infallible>(encode_frame(&headers_frame).expect("headers frame should encode"));
let mut upstream_bytes = 0u64;
let mut bytes_stream = upstream_response.bytes_stream();
while let Some(item) = bytes_stream.next().await {
match item {
Ok(chunk) => {
upstream_bytes += chunk.len() as u64;
let frame = StreamFrame {
frame_type: StreamFrameType::Data,
payload: StreamFramePayload::Data {
chunk_b64: Some(base64::engine::general_purpose::STANDARD.encode(&chunk)),
text: None,
},
};
yield Ok::<Bytes, Infallible>(encode_frame(&frame).expect("data frame should encode"));
}
Err(err) => {
let frame = StreamFrame {
frame_type: StreamFrameType::Error,
payload: StreamFramePayload::Error {
error: ExecutionError {
kind: ExecutionErrorKind::Internal,
phase: ExecutionPhase::StreamRead,
message: err.to_string(),
upstream_status: Some(status_code),
retryable: false,
failover_recommended: false,
},
},
};
yield Ok::<Bytes, Infallible>(encode_frame(&frame).expect("error frame should encode"));
break;
}
}
}
let telemetry_frame = StreamFrame {
frame_type: StreamFrameType::Telemetry,
payload: StreamFramePayload::Telemetry {
telemetry: ExecutionTelemetry {
ttfb_ms: None,
elapsed_ms: Some(started_at.elapsed().as_millis() as u64),
upstream_bytes: Some(upstream_bytes),
},
},
};
yield Ok::<Bytes, Infallible>(encode_frame(&telemetry_frame).expect("telemetry frame should encode"));
yield Ok::<Bytes, Infallible>(encode_frame(&StreamFrame::eof()).expect("eof frame should encode"));
};
let mut response = Response::new(Body::from_stream(body_stream));
*response.status_mut() = StatusCode::OK;
response.headers_mut().insert(
axum::http::header::CONTENT_TYPE,
axum::http::HeaderValue::from_static("application/x-ndjson"),
);
Ok(response)
}
#[derive(Debug)]
struct AppError(ExecutorServiceError);
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let status_code = match self.0 {
ExecutorServiceError::StreamUnsupported
| ExecutorServiceError::RequestBodyRequired
| ExecutorServiceError::BodyDecode(_)
| ExecutorServiceError::UnsupportedContentEncoding(_)
| ExecutorServiceError::ProxyUnsupported
| ExecutorServiceError::TlsProfileUnsupported
| ExecutorServiceError::DelegateUnsupported
| ExecutorServiceError::InvalidMethod(_)
| ExecutorServiceError::InvalidHeaderName(_)
| ExecutorServiceError::InvalidHeaderValue(_)
| ExecutorServiceError::InvalidProxy(_)
| ExecutorServiceError::BodyEncode(_) => StatusCode::BAD_REQUEST,
ExecutorServiceError::ClientBuild(_)
| ExecutorServiceError::UpstreamRequest(_)
| ExecutorServiceError::RelayError(_)
| ExecutorServiceError::InvalidJson(_) => StatusCode::BAD_GATEWAY,
};
(
status_code,
Json(json!({
"error": self.0.to_string(),
})),
)
.into_response()
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TransportMode {
UnixSocketHttp,
TcpHttp,
}