mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
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:
57
crates/aether-executor/src/client.rs
Normal file
57
crates/aether-executor/src/client.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
48
crates/aether-executor/src/error.rs
Normal file
48
crates/aether-executor/src/error.rs
Normal 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),
|
||||
}
|
||||
12
crates/aether-executor/src/lib.rs
Normal file
12
crates/aether-executor/src/lib.rs
Normal 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;
|
||||
52
crates/aether-executor/src/main.rs
Normal file
52
crates/aether-executor/src/main.rs
Normal 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(())
|
||||
}
|
||||
38
crates/aether-executor/src/ndjson.rs
Normal file
38
crates/aether-executor/src/ndjson.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
187
crates/aether-executor/src/server.rs
Normal file
187
crates/aether-executor/src/server.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
1222
crates/aether-executor/src/service.rs
Normal file
1222
crates/aether-executor/src/service.rs
Normal file
File diff suppressed because it is too large
Load Diff
5
crates/aether-executor/src/transport.rs
Normal file
5
crates/aether-executor/src/transport.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TransportMode {
|
||||
UnixSocketHttp,
|
||||
TcpHttp,
|
||||
}
|
||||
Reference in New Issue
Block a user