Implement transport profile routing

This commit is contained in:
fawney19
2026-05-05 22:21:23 +08:00
parent aacab1a90c
commit f959f02d40
86 changed files with 860 additions and 318 deletions

View File

@@ -364,7 +364,7 @@ mod tests {
provider_api_format: "openai:chat".to_string(),
model_name: Some("gpt-5".to_string()),
proxy: None,
tls_profile: None,
transport_profile: None,
timeouts: None,
}
}

View File

@@ -307,7 +307,7 @@ async fn execute_mcp_request(
provider_api_format: plan.provider_api_format.clone(),
model_name: plan.model_name.clone(),
proxy: plan.proxy.clone(),
tls_profile: plan.tls_profile.clone(),
transport_profile: plan.transport_profile.clone(),
timeouts: plan.timeouts.clone(),
};
let result = DirectSyncExecutionRuntime::new()
@@ -538,7 +538,7 @@ async fn discover_kiro_profile_arn_in_region(
provider_api_format: "kiro:profiles".to_string(),
model_name: Some("kiro-list-available-profiles".to_string()),
proxy: plan.proxy.clone(),
tls_profile: plan.tls_profile.clone(),
transport_profile: plan.transport_profile.clone(),
timeouts: plan.timeouts.clone(),
};
let result = DirectSyncExecutionRuntime::new()
@@ -1208,7 +1208,7 @@ mod tests {
provider_api_format: "claude:messages".to_string(),
model_name: Some("claude-sonnet-4.6".to_string()),
proxy: None,
tls_profile: None,
transport_profile: None,
timeouts: None,
}
}

View File

@@ -352,6 +352,7 @@ impl IntoResponse for ExecutionRuntimeAppError {
| ExecutionRuntimeTransportError::InvalidHeaderName(_)
| ExecutionRuntimeTransportError::InvalidHeaderValue(_)
| ExecutionRuntimeTransportError::InvalidProxy(_)
| ExecutionRuntimeTransportError::UnsupportedTransportProfile(_)
| ExecutionRuntimeTransportError::BodyEncode(_),
) => StatusCode::BAD_REQUEST,
ExecutionRuntimeServerError::Transport(
@@ -422,7 +423,7 @@ mod tests {
provider_api_format: "openai:chat".into(),
model_name: Some("gpt-4.1".into()),
proxy: None,
tls_profile: None,
transport_profile: None,
timeouts: Some(ExecutionTimeouts {
connect_ms: Some(5_000),
total_ms: Some(30_000),

View File

@@ -2504,7 +2504,7 @@ mod tests {
provider_api_format: "openai:responses".into(),
model_name: Some("gpt-5.4".into()),
proxy: None,
tls_profile: None,
transport_profile: None,
timeouts: Some(ExecutionTimeouts {
connect_ms: Some(5_000),
total_ms: Some(5_000),
@@ -2625,7 +2625,7 @@ mod tests {
provider_api_format: "openai:responses".into(),
model_name: Some("gpt-5.4".into()),
proxy: None,
tls_profile: None,
transport_profile: None,
timeouts: Some(ExecutionTimeouts {
connect_ms: Some(5_000),
total_ms: Some(5_000),
@@ -2733,7 +2733,7 @@ mod tests {
provider_api_format: "openai:image".into(),
model_name: Some("gpt-image-1".into()),
proxy: None,
tls_profile: None,
transport_profile: None,
timeouts: Some(ExecutionTimeouts {
connect_ms: Some(5_000),
total_ms: Some(5_000),
@@ -2822,7 +2822,7 @@ mod tests {
provider_api_format: "openai:chat".into(),
model_name: Some("gpt-5".into()),
proxy: Some(tunnel_proxy_snapshot("http://127.0.0.1:1".to_string())),
tls_profile: None,
transport_profile: None,
timeouts: Some(ExecutionTimeouts {
connect_ms: Some(5_000),
total_ms: Some(5_000),
@@ -2951,7 +2951,7 @@ mod tests {
provider_api_format: "openai:chat".into(),
model_name: Some("gpt-5".into()),
proxy: Some(tunnel_proxy_snapshot("http://127.0.0.1:1".to_string())),
tls_profile: None,
transport_profile: None,
timeouts: Some(ExecutionTimeouts {
connect_ms: Some(5_000),
total_ms: Some(5_000),

View File

@@ -777,7 +777,7 @@ mod tests {
provider_api_format: "openai:chat".into(),
model_name: Some("gpt-5".into()),
proxy: None,
tls_profile: None,
transport_profile: None,
timeouts: Some(ExecutionTimeouts {
connect_ms: Some(5_000),
total_ms: Some(5_000),
@@ -868,7 +868,7 @@ mod tests {
provider_api_format: "openai:chat".to_string(),
model_name: Some("gpt-5".into()),
proxy: None,
tls_profile: None,
transport_profile: None,
timeouts: Some(ExecutionTimeouts {
connect_ms: Some(5_000),
total_ms: Some(5_000),
@@ -986,7 +986,7 @@ mod tests {
provider_api_format: "openai:responses".to_string(),
model_name: Some("gpt-5.4".into()),
proxy: None,
tls_profile: None,
transport_profile: None,
timeouts: Some(ExecutionTimeouts {
connect_ms: Some(5_000),
total_ms: Some(5_000),
@@ -1115,7 +1115,7 @@ mod tests {
provider_api_format: "openai:image".to_string(),
model_name: Some("gpt-image-1".into()),
proxy: None,
tls_profile: None,
transport_profile: None,
timeouts: Some(ExecutionTimeouts {
connect_ms: Some(5_000),
total_ms: Some(5_000),
@@ -1224,7 +1224,7 @@ mod tests {
provider_api_format: "openai:chat".into(),
model_name: Some("gpt-5".into()),
proxy: Some(tunnel_proxy_snapshot("http://127.0.0.1:1".to_string())),
tls_profile: None,
transport_profile: None,
timeouts: Some(ExecutionTimeouts {
connect_ms: Some(5_000),
total_ms: Some(5_000),
@@ -1368,7 +1368,7 @@ mod tests {
provider_api_format: "openai:chat".into(),
model_name: Some("gpt-5".into()),
proxy: Some(tunnel_proxy_snapshot("http://127.0.0.1:1".to_string())),
tls_profile: None,
transport_profile: None,
timeouts: Some(ExecutionTimeouts {
connect_ms: Some(5_000),
total_ms: Some(5_000),

View File

@@ -64,7 +64,7 @@ fn missing_exact_provider_request_payload(decision_kind: &str) -> AiExecutionDec
provider_request_body_base64: None,
content_type: Some("application/json".to_string()),
proxy: None,
tls_profile: None,
transport_profile: None,
timeouts: None,
upstream_is_stream: false,
report_kind: Some("openai_chat_sync_success".to_string()),
@@ -806,7 +806,7 @@ fn bypasses_execution_runtime_for_codex_plan_variant() {
provider_api_format: "openai:responses".to_string(),
model_name: Some("gpt-5.4".to_string()),
proxy: None,
tls_profile: None,
transport_profile: None,
timeouts: None,
};

View File

@@ -5,8 +5,9 @@ use std::io::Write;
use std::time::{Duration, Instant};
use aether_contracts::{
ExecutionPlan, ExecutionResult, ExecutionTelemetry, ProxySnapshot, ResponseBody,
EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER, EXECUTION_REQUEST_HTTP1_ONLY_HEADER,
ExecutionPlan, ExecutionResult, ExecutionTelemetry, ProxySnapshot, ResolvedTransportProfile,
ResponseBody, EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER, EXECUTION_REQUEST_HTTP1_ONLY_HEADER,
TRANSPORT_BACKEND_REQWEST_RUSTLS, TRANSPORT_HTTP_MODE_HTTP1_ONLY,
};
use aether_data::repository::proxy_nodes::ProxyNodeTrafficMutation;
use aether_http::{apply_http_client_config, HttpClientConfig};
@@ -102,6 +103,8 @@ pub(crate) enum ExecutionRuntimeTransportError {
InvalidHeaderValue(String),
#[error("invalid proxy configuration: {0}")]
InvalidProxy(reqwest::Error),
#[error("unsupported transport profile backend: {0}")]
UnsupportedTransportProfile(String),
#[error("failed to encode request body: {0}")]
BodyEncode(serde_json::Error),
#[error("failed to build HTTP client: {0}")]
@@ -116,6 +119,9 @@ pub(crate) enum ExecutionRuntimeTransportError {
#[derive(Debug, Serialize)]
struct RelayRequestMeta {
provider_id: String,
endpoint_id: String,
key_id: String,
method: String,
url: String,
headers: BTreeMap<String, String>,
@@ -124,6 +130,8 @@ struct RelayRequestMeta {
follow_redirects: Option<bool>,
#[serde(default, skip_serializing_if = "is_false")]
http1_only: bool,
#[serde(skip_serializing_if = "Option::is_none")]
transport_profile: Option<ResolvedTransportProfile>,
}
#[derive(Debug, Clone, Default)]
@@ -531,12 +539,16 @@ fn build_direct_tunnel_request_meta(
transport_controls: ExecutionTransportControls,
) -> tunnel_protocol::RequestMeta {
tunnel_protocol::RequestMeta {
provider_id: Some(plan.provider_id.clone()),
endpoint_id: Some(plan.endpoint_id.clone()),
key_id: Some(plan.key_id.clone()),
method: plan.method.clone(),
url: plan.url.clone(),
headers: header_map_to_string_map(headers).into_iter().collect(),
timeout: resolve_relay_timeout_seconds(plan),
follow_redirects: transport_controls.follow_redirects,
http1_only: transport_controls.http1_only,
transport_profile: plan.transport_profile.clone(),
}
}
@@ -577,7 +589,7 @@ async fn send_request(
let client = build_client(
plan.timeouts.as_ref(),
plan.proxy.as_ref(),
plan.tls_profile.as_deref(),
plan.transport_profile.as_ref(),
transport_controls,
)?;
let mut request = client.request(method, &plan.url);
@@ -604,12 +616,16 @@ async fn send_via_tunnel_relay(
let timeout_secs = resolve_relay_timeout_seconds(plan);
let envelope = build_relay_envelope(
RelayRequestMeta {
provider_id: plan.provider_id.clone(),
endpoint_id: plan.endpoint_id.clone(),
key_id: plan.key_id.clone(),
method: method.as_str().to_string(),
url: plan.url.clone(),
headers: header_map_to_string_map(&headers),
timeout: timeout_secs,
follow_redirects: transport_controls.follow_redirects,
http1_only: transport_controls.http1_only,
transport_profile: plan.transport_profile.clone(),
},
&body_bytes,
)?;
@@ -856,14 +872,15 @@ fn resolve_local_tunnel_node_id(state: &AppState, proxy: Option<&ProxySnapshot>)
fn build_client(
timeouts: Option<&aether_contracts::ExecutionTimeouts>,
proxy: Option<&ProxySnapshot>,
tls_profile: Option<&str>,
transport_profile: Option<&ResolvedTransportProfile>,
transport_controls: ExecutionTransportControls,
) -> Result<reqwest::Client, ExecutionRuntimeTransportError> {
validate_reqwest_transport_profile(transport_profile)?;
let mut builder = reqwest::Client::builder();
if transport_controls.follow_redirects != Some(true) {
builder = builder.redirect(Policy::none());
}
if transport_controls.http1_only {
if transport_controls.http1_only || transport_profile_http1_only(transport_profile) {
builder = builder.http1_only();
}
let mut builder = apply_http_client_config(
@@ -873,7 +890,10 @@ fn build_client(
..HttpClientConfig::default()
},
);
builder = apply_tls_profile(builder, tls_profile);
builder = apply_tls_profile(
builder,
transport_profile.map(|profile| profile.profile_id.as_str()),
);
if let Some(proxy_url) = resolve_proxy_url(proxy)? {
let proxy = reqwest::Proxy::all(&proxy_url)
.map_err(ExecutionRuntimeTransportError::InvalidProxy)?;
@@ -884,11 +904,40 @@ fn build_client(
.map_err(ExecutionRuntimeTransportError::ClientBuild)
}
fn validate_reqwest_transport_profile(
transport_profile: Option<&ResolvedTransportProfile>,
) -> Result<(), ExecutionRuntimeTransportError> {
let Some(profile) = transport_profile else {
return Ok(());
};
if profile
.backend
.trim()
.eq_ignore_ascii_case(TRANSPORT_BACKEND_REQWEST_RUSTLS)
{
return Ok(());
}
Err(ExecutionRuntimeTransportError::UnsupportedTransportProfile(
profile.backend.clone(),
))
}
fn transport_profile_http1_only(transport_profile: Option<&ResolvedTransportProfile>) -> bool {
transport_profile
.map(|profile| {
profile
.http_mode
.trim()
.eq_ignore_ascii_case(TRANSPORT_HTTP_MODE_HTTP1_ONLY)
})
.unwrap_or(false)
}
fn apply_tls_profile(
builder: reqwest::ClientBuilder,
tls_profile: Option<&str>,
profile_id: Option<&str>,
) -> reqwest::ClientBuilder {
let profile = normalize_tls_profile(tls_profile);
let profile = normalize_tls_profile(profile_id);
if profile.is_none() {
return builder;
}
@@ -1136,7 +1185,7 @@ mod tests {
use std::sync::Arc;
use aether_contracts::{
ExecutionPlan, ExecutionTimeouts, ProxySnapshot, RequestBody,
ExecutionPlan, ExecutionTimeouts, ProxySnapshot, RequestBody, ResolvedTransportProfile,
EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER, EXECUTION_REQUEST_HTTP1_ONLY_HEADER,
};
use aether_data::repository::proxy_nodes::{
@@ -1154,7 +1203,8 @@ mod tests {
use super::{
build_client, execute_sync_plan, record_manual_proxy_request_failure,
record_manual_proxy_request_outcome, record_manual_proxy_request_success,
record_manual_proxy_stream_error, DirectSyncExecutionRuntime, ExecutionTransportControls,
record_manual_proxy_stream_error, DirectSyncExecutionRuntime,
ExecutionRuntimeTransportError, ExecutionTransportControls,
};
use crate::constants::{
EXECUTION_RUNTIME_LOOP_GUARD_HEADER, EXECUTION_RUNTIME_LOOP_GUARD_VIA_TOKEN,
@@ -1346,7 +1396,7 @@ mod tests {
provider_api_format: "openai:chat".into(),
model_name: Some("gpt-4.1".into()),
proxy: None,
tls_profile: None,
transport_profile: None,
timeouts: Some(ExecutionTimeouts {
connect_ms: Some(5_000),
total_ms: Some(5_000),
@@ -1394,7 +1444,7 @@ mod tests {
provider_api_format: "openai:chat".into(),
model_name: None,
proxy: Some(manual_proxy_snapshot("manual-node-1")),
tls_profile: None,
transport_profile: None,
timeouts: None,
};
@@ -1438,7 +1488,7 @@ mod tests {
provider_api_format: "openai:chat".into(),
model_name: None,
proxy: Some(manual_proxy_snapshot("manual-node-1")),
tls_profile: None,
transport_profile: None,
timeouts: None,
};
@@ -1482,7 +1532,7 @@ mod tests {
provider_api_format: "openai:chat".into(),
model_name: None,
proxy: Some(manual_proxy_snapshot("manual-node-1")),
tls_profile: None,
transport_profile: None,
timeouts: None,
};
@@ -1526,7 +1576,7 @@ mod tests {
provider_api_format: "openai:chat".into(),
model_name: None,
proxy: Some(manual_proxy_snapshot("manual-node-1")),
tls_profile: None,
transport_profile: None,
timeouts: None,
};
@@ -1570,7 +1620,7 @@ mod tests {
provider_api_format: "openai:chat".into(),
model_name: None,
proxy: Some(manual_proxy_snapshot("manual-node-1")),
tls_profile: None,
transport_profile: None,
timeouts: None,
};
@@ -1616,7 +1666,7 @@ mod tests {
provider_api_format: "openai:chat".into(),
model_name: None,
proxy: Some(tunnel_proxy_snapshot("http://127.0.0.1:1".to_string())),
tls_profile: None,
transport_profile: None,
timeouts: None,
};
@@ -1696,7 +1746,7 @@ mod tests {
provider_api_format: "openai:chat".into(),
model_name: Some("gpt-4.1".into()),
proxy: Some(tunnel_proxy_snapshot(format!("http://{addr}"))),
tls_profile: None,
transport_profile: None,
timeouts: Some(ExecutionTimeouts {
connect_ms: Some(5_000),
total_ms: Some(5_000),
@@ -1748,7 +1798,7 @@ mod tests {
provider_api_format: "openai:chat".into(),
model_name: Some("gpt-4.1".into()),
proxy: Some(tunnel_proxy_snapshot("http://127.0.0.1:1".to_string())),
tls_profile: None,
transport_profile: None,
timeouts: Some(ExecutionTimeouts {
connect_ms: Some(5_000),
total_ms: Some(5_000),
@@ -1897,7 +1947,7 @@ mod tests {
provider_api_format: "provider_ops:verify".into(),
model_name: Some("verify-auth".into()),
proxy: None,
tls_profile: None,
transport_profile: None,
timeouts: Some(ExecutionTimeouts {
connect_ms: Some(5_000),
total_ms: Some(5_000),
@@ -1976,7 +2026,7 @@ mod tests {
provider_api_format: "provider_oauth:exchange".into(),
model_name: Some("oauth-exchange".into()),
proxy: None,
tls_profile: None,
transport_profile: None,
timeouts: Some(ExecutionTimeouts {
connect_ms: Some(5_000),
total_ms: Some(5_000),
@@ -2006,8 +2056,12 @@ mod tests {
post(|Path(node_id): Path<String>, body: Bytes| async move {
let (meta, request_body) = decode_relay_envelope(&body);
assert_eq!(node_id, "node-1");
assert_eq!(meta["provider_id"], "prov-1");
assert_eq!(meta["endpoint_id"], "ep-1");
assert_eq!(meta["key_id"], "key-1");
assert_eq!(meta["http1_only"], true);
assert_eq!(meta["follow_redirects"], json!(false));
assert_eq!(meta["transport_profile"]["profile_id"], "relay-profile");
let request_json: serde_json::Value =
serde_json::from_slice(&request_body).expect("request body should be json");
assert_eq!(request_json["model"], "gpt-4.1");
@@ -2047,7 +2101,9 @@ mod tests {
provider_api_format: "provider_ops:verify".into(),
model_name: Some("verify-auth".into()),
proxy: Some(tunnel_proxy_snapshot(format!("http://{addr}"))),
tls_profile: None,
transport_profile: Some(ResolvedTransportProfile::from_legacy_tls_profile(
"relay-profile",
)),
timeouts: Some(ExecutionTimeouts {
connect_ms: Some(5_000),
total_ms: Some(5_000),
@@ -2107,7 +2163,7 @@ mod tests {
provider_api_format: "claude:messages".into(),
model_name: Some("claude-3.7-sonnet".into()),
proxy: None,
tls_profile: Some("claude_code_nodejs".into()),
transport_profile: None,
timeouts: Some(ExecutionTimeouts {
connect_ms: Some(5_000),
total_ms: Some(5_000),
@@ -2126,6 +2182,33 @@ mod tests {
);
}
#[test]
fn direct_sync_execution_runtime_rejects_unsupported_transport_backend() {
let profile = ResolvedTransportProfile {
profile_id: "chrome-120".into(),
backend: "utls".into(),
http_mode: "auto".into(),
pool_scope: "key".into(),
extra: None,
};
let error = match build_client(
None,
None,
Some(&profile),
ExecutionTransportControls::default(),
) {
Ok(_) => panic!("unsupported backend should fail"),
Err(error) => error,
};
assert!(matches!(
error,
ExecutionRuntimeTransportError::UnsupportedTransportProfile(backend)
if backend == "utls"
));
}
#[tokio::test]
async fn direct_sync_execution_runtime_compresses_json_body_when_requested() {
let listener = crate::test_support::bind_loopback_listener()
@@ -2182,7 +2265,7 @@ mod tests {
provider_api_format: "openai:chat".into(),
model_name: Some("gpt-4.1".into()),
proxy: None,
tls_profile: None,
transport_profile: None,
timeouts: Some(ExecutionTimeouts {
connect_ms: Some(5_000),
total_ms: Some(5_000),
@@ -2243,7 +2326,7 @@ mod tests {
provider_api_format: "openai:chat".into(),
model_name: Some("gpt-4.1".into()),
proxy: None,
tls_profile: None,
transport_profile: None,
timeouts: Some(ExecutionTimeouts {
connect_ms: Some(5_000),
total_ms: Some(5_000),