mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +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:
41
crates/aether-contracts/src/error.rs
Normal file
41
crates/aether-contracts/src/error.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ExecutionErrorKind {
|
||||
ConnectTimeout,
|
||||
FirstByteTimeout,
|
||||
ReadTimeout,
|
||||
Upstream4xx,
|
||||
Upstream5xx,
|
||||
TlsError,
|
||||
ProxyError,
|
||||
ProtocolError,
|
||||
Cancelled,
|
||||
Internal,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ExecutionPhase {
|
||||
Connect,
|
||||
Handshake,
|
||||
Write,
|
||||
FirstByte,
|
||||
StreamRead,
|
||||
Decode,
|
||||
Finalize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ExecutionError {
|
||||
pub kind: ExecutionErrorKind,
|
||||
pub phase: ExecutionPhase,
|
||||
pub message: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub upstream_status: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub retryable: bool,
|
||||
#[serde(default)]
|
||||
pub failover_recommended: bool,
|
||||
}
|
||||
58
crates/aether-contracts/src/frame.rs
Normal file
58
crates/aether-contracts/src/frame.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{ExecutionError, ExecutionTelemetry};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum StreamFrameType {
|
||||
Headers,
|
||||
Data,
|
||||
Error,
|
||||
Telemetry,
|
||||
Eof,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum StreamFramePayload {
|
||||
Headers {
|
||||
status_code: u16,
|
||||
#[serde(default)]
|
||||
headers: BTreeMap<String, String>,
|
||||
},
|
||||
Data {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
chunk_b64: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
text: Option<String>,
|
||||
},
|
||||
Error {
|
||||
error: ExecutionError,
|
||||
},
|
||||
Telemetry {
|
||||
telemetry: ExecutionTelemetry,
|
||||
},
|
||||
Eof {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
summary: Option<Value>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct StreamFrame {
|
||||
#[serde(rename = "type")]
|
||||
pub frame_type: StreamFrameType,
|
||||
pub payload: StreamFramePayload,
|
||||
}
|
||||
|
||||
impl StreamFrame {
|
||||
pub fn eof() -> Self {
|
||||
Self {
|
||||
frame_type: StreamFrameType::Eof,
|
||||
payload: StreamFramePayload::Eof { summary: None },
|
||||
}
|
||||
}
|
||||
}
|
||||
9
crates/aether-contracts/src/lib.rs
Normal file
9
crates/aether-contracts/src/lib.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
mod error;
|
||||
mod frame;
|
||||
mod plan;
|
||||
mod result;
|
||||
|
||||
pub use error::{ExecutionError, ExecutionErrorKind, ExecutionPhase};
|
||||
pub use frame::{StreamFrame, StreamFramePayload, StreamFrameType};
|
||||
pub use plan::{ExecutionPlan, ExecutionTimeouts, ProxySnapshot, RequestBody};
|
||||
pub use result::{ExecutionResult, ExecutionTelemetry, ResponseBody};
|
||||
196
crates/aether-contracts/src/plan.rs
Normal file
196
crates/aether-contracts/src/plan.rs
Normal file
@@ -0,0 +1,196 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(default)]
|
||||
pub struct ExecutionTimeouts {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub connect_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub read_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub first_byte_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub write_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pool_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub total_ms: Option<u64>,
|
||||
}
|
||||
|
||||
impl Default for ExecutionTimeouts {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
connect_ms: None,
|
||||
read_ms: None,
|
||||
first_byte_ms: None,
|
||||
write_ms: None,
|
||||
pool_ms: None,
|
||||
total_ms: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct RequestBody {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub json_body: Option<Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub body_bytes_b64: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub body_ref: Option<String>,
|
||||
}
|
||||
|
||||
impl RequestBody {
|
||||
pub fn from_json(json_body: Value) -> Self {
|
||||
Self {
|
||||
json_body: Some(json_body),
|
||||
body_bytes_b64: None,
|
||||
body_ref: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ProxySnapshot {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub mode: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub node_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub label: Option<String>,
|
||||
#[serde(default, alias = "proxy_url", skip_serializing_if = "Option::is_none")]
|
||||
pub url: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub extra: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ExecutionPlan {
|
||||
pub request_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub candidate_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub provider_name: Option<String>,
|
||||
pub provider_id: String,
|
||||
pub endpoint_id: String,
|
||||
pub key_id: String,
|
||||
pub method: String,
|
||||
#[serde(alias = "upstream_url")]
|
||||
pub url: String,
|
||||
#[serde(default)]
|
||||
pub headers: BTreeMap<String, String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub content_type: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub content_encoding: Option<String>,
|
||||
pub body: RequestBody,
|
||||
#[serde(default)]
|
||||
pub stream: bool,
|
||||
pub client_api_format: String,
|
||||
pub provider_api_format: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub model_name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub proxy: Option<ProxySnapshot>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tls_profile: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub timeouts: Option<ExecutionTimeouts>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn serializes_plan_with_json_body() {
|
||||
let plan = ExecutionPlan {
|
||||
request_id: "req_123".into(),
|
||||
candidate_id: Some("cand_123".into()),
|
||||
provider_name: Some("openai".into()),
|
||||
provider_id: "prov_123".into(),
|
||||
endpoint_id: "ep_123".into(),
|
||||
key_id: "key_123".into(),
|
||||
method: "POST".into(),
|
||||
url: "https://example.com/v1/chat/completions".into(),
|
||||
headers: BTreeMap::from([("authorization".into(), "Bearer test".into())]),
|
||||
content_type: Some("application/json".into()),
|
||||
content_encoding: Some("gzip".into()),
|
||||
body: RequestBody::from_json(serde_json::json!({"model":"gpt-test"})),
|
||||
stream: true,
|
||||
client_api_format: "openai:chat".into(),
|
||||
provider_api_format: "openai:chat".into(),
|
||||
model_name: Some("gpt-test".into()),
|
||||
proxy: None,
|
||||
tls_profile: Some("chrome".into()),
|
||||
timeouts: Some(ExecutionTimeouts {
|
||||
connect_ms: Some(30_000),
|
||||
read_ms: Some(3_600_000),
|
||||
first_byte_ms: Some(30_000),
|
||||
..ExecutionTimeouts::default()
|
||||
}),
|
||||
};
|
||||
|
||||
let raw = serde_json::to_value(&plan).expect("plan should serialize");
|
||||
assert_eq!(raw["body"]["json_body"]["model"], "gpt-test");
|
||||
assert_eq!(raw["content_encoding"], "gzip");
|
||||
assert_eq!(raw["stream"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserializes_python_control_plane_plan_shape() {
|
||||
let raw = serde_json::json!({
|
||||
"request_id": "req-1",
|
||||
"candidate_id": null,
|
||||
"provider_name": "openai",
|
||||
"provider_id": "prov-1",
|
||||
"endpoint_id": "ep-1",
|
||||
"key_id": "key-1",
|
||||
"method": "POST",
|
||||
"url": "https://example.com/v1/chat/completions",
|
||||
"headers": {"content-type": "application/json"},
|
||||
"content_encoding": "gzip",
|
||||
"body": {"json_body": {"model": "gpt-4.1"}},
|
||||
"stream": false,
|
||||
"provider_api_format": "openai:chat",
|
||||
"client_api_format": "openai:chat",
|
||||
"model_name": "gpt-4.1",
|
||||
"proxy": {
|
||||
"enabled": true,
|
||||
"mode": "direct",
|
||||
"label": "no-proxy",
|
||||
"url": "http://proxy.internal"
|
||||
},
|
||||
"timeouts": {
|
||||
"connect_ms": 10000,
|
||||
"read_ms": 30000,
|
||||
"write_ms": 30000,
|
||||
"pool_ms": 10000,
|
||||
"total_ms": 300000
|
||||
}
|
||||
});
|
||||
|
||||
let plan: ExecutionPlan =
|
||||
serde_json::from_value(raw).expect("python payload should deserialize");
|
||||
assert_eq!(plan.url, "https://example.com/v1/chat/completions");
|
||||
assert_eq!(plan.candidate_id, None);
|
||||
assert_eq!(plan.provider_name.as_deref(), Some("openai"));
|
||||
assert_eq!(plan.model_name.as_deref(), Some("gpt-4.1"));
|
||||
assert_eq!(plan.content_encoding.as_deref(), Some("gzip"));
|
||||
assert_eq!(
|
||||
plan.proxy.as_ref().and_then(|proxy| proxy.url.as_deref()),
|
||||
Some("http://proxy.internal")
|
||||
);
|
||||
assert_eq!(
|
||||
plan.timeouts
|
||||
.as_ref()
|
||||
.and_then(|timeouts| timeouts.total_ms),
|
||||
Some(300_000)
|
||||
);
|
||||
}
|
||||
}
|
||||
40
crates/aether-contracts/src/result.rs
Normal file
40
crates/aether-contracts/src/result.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::ExecutionError;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ExecutionTelemetry {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ttfb_ms: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub elapsed_ms: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub upstream_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ResponseBody {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub json_body: Option<Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub body_bytes_b64: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ExecutionResult {
|
||||
pub request_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub candidate_id: Option<String>,
|
||||
pub status_code: u16,
|
||||
#[serde(default)]
|
||||
pub headers: BTreeMap<String, String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub body: Option<ResponseBody>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub telemetry: Option<ExecutionTelemetry>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<ExecutionError>,
|
||||
}
|
||||
Reference in New Issue
Block a user