feat(image): 接入 ChatGPT Web 生图反代

This commit is contained in:
Entropy.Xu
2026-05-06 02:29:17 +08:00
parent beee7a76d2
commit 4baee436ba
47 changed files with 3872 additions and 141 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -963,4 +963,53 @@ mod tests {
.await
);
}
#[tokio::test]
async fn chatgpt_web_report_context_stops_local_sync_failover_on_transport_errors() {
let result = ExecutionResult {
request_id: "req-1".to_string(),
candidate_id: None,
status_code: 503,
headers: Default::default(),
body: None,
telemetry: None,
error: None,
};
let local_report_context = serde_json::json!({
"chatgpt_web_image": true,
"candidate_index": 0,
"retry_index": 0,
"local_failover_policy": {
"stop_status_codes": [400, 401, 403, 429, 500, 502, 503, 504],
"error_stop_patterns": [
{"pattern": ".*"}
]
}
});
let state = build_state_with_provider_config(None);
let plan = sample_plan();
assert!(
should_stop_local_candidate_failover_sync(
&state,
&plan,
"openai_image_sync",
Some(&local_report_context),
&result,
Some("{\"error\":{\"code\":\"chatgpt_web_image_execution_unavailable\"}}"),
)
.await
);
assert!(
!should_retry_next_local_candidate_sync(
&state,
&plan,
"openai_image_sync",
Some(&local_report_context),
&result,
Some("{\"error\":{\"code\":\"chatgpt_web_image_execution_unavailable\"}}"),
)
.await
);
}
}

View File

@@ -3,6 +3,7 @@ use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
mod chatgpt_web_image;
mod constants;
mod fallback;
mod kiro_web_search;

View File

@@ -51,6 +51,7 @@ use crate::clock::current_unix_ms as current_request_candidate_unix_ms;
use crate::constants::{CONTROL_CANDIDATE_ID_HEADER, CONTROL_REQUEST_ID_HEADER};
use crate::control::GatewayControlDecision;
use crate::execution_runtime::build_direct_execution_frame_stream;
use crate::execution_runtime::chatgpt_web_image::maybe_execute_chatgpt_web_image_stream;
use crate::execution_runtime::kiro_web_search::maybe_execute_kiro_web_search_stream;
use crate::execution_runtime::oauth_retry::refresh_oauth_plan_auth_for_retry;
#[cfg(test)]
@@ -447,6 +448,57 @@ pub(crate) async fn execute_execution_runtime_stream(
return Ok(None);
}
}
match maybe_execute_chatgpt_web_image_stream(state, &plan, report_context.as_ref()).await {
Ok(Some(chatgpt_web_image)) => {
return execute_stream_from_frame_stream(
state,
plan,
trace_id,
decision,
plan_kind,
report_kind,
chatgpt_web_image.report_context.or(report_context),
candidate_started_unix_secs,
stream_started_at,
chatgpt_web_image.frame_stream,
)
.await;
}
Ok(None) => {}
Err(err) => {
info!(
event_name = "chatgpt_web_image_execution_unavailable",
log_type = "ops",
trace_id = %trace_id,
request_id = %plan_request_id_for_log,
candidate_id = ?plan.candidate_id,
provider_name = provider_name.as_str(),
endpoint_id = %endpoint_id,
key_id = %key_id,
model_name = model_name.as_str(),
candidate_index = candidate_index.as_str(),
error = %err,
"gateway ChatGPT-Web image stream execution unavailable"
);
let terminal_unix_secs = current_request_candidate_unix_ms();
record_local_request_candidate_status(
state,
&plan,
report_context.as_ref(),
SchedulerRequestCandidateStatusUpdate {
status: RequestCandidateStatus::Failed,
status_code: None,
error_type: Some("chatgpt_web_image_execution_unavailable".to_string()),
error_message: Some(format!("{err:?}")),
latency_ms: None,
started_at_unix_ms: Some(candidate_started_unix_secs),
finished_at_unix_ms: Some(terminal_unix_secs),
},
)
.await;
return Ok(None);
}
}
#[cfg(not(test))]
{
let execution = match execute_in_process_stream_with_oauth_retry(

View File

@@ -24,6 +24,7 @@ use crate::api::response::{
};
use crate::clock::current_unix_ms as current_request_candidate_unix_ms;
use crate::control::GatewayControlDecision;
use crate::execution_runtime::chatgpt_web_image::maybe_execute_chatgpt_web_image_sync;
use crate::execution_runtime::oauth_retry::refresh_oauth_plan_auth_for_retry;
#[cfg(test)]
use crate::execution_runtime::remote_compat::post_sync_plan_to_remote_execution_runtime;
@@ -193,11 +194,47 @@ pub(crate) async fn execute_execution_runtime_sync(
.await;
#[cfg(not(test))]
let mut result = {
match DirectSyncExecutionRuntime::new().execute_sync(&plan).await {
Ok(result) => result,
match maybe_execute_chatgpt_web_image_sync(state, &plan, report_context.as_ref()).await {
Ok(Some(result)) => result,
Ok(None) => match DirectSyncExecutionRuntime::new().execute_sync(&plan).await {
Ok(result) => result,
Err(err) => {
warn!(
event_name = "sync_execution_runtime_unavailable",
log_type = "ops",
trace_id = %trace_id,
request_id = %plan_request_id_for_log,
candidate_id = ?plan_candidate_id,
provider_name,
endpoint_id,
key_id,
model_name,
candidate_index = candidate_index.as_str(),
error = %err,
"gateway in-process sync execution unavailable"
);
let terminal_unix_secs = current_request_candidate_unix_ms();
record_local_request_candidate_status(
state,
&plan,
report_context.as_ref(),
SchedulerRequestCandidateStatusUpdate {
status: RequestCandidateStatus::Failed,
status_code: None,
error_type: Some("execution_runtime_unavailable".to_string()),
error_message: Some(err.to_string()),
latency_ms: None,
started_at_unix_ms: Some(candidate_started_unix_secs),
finished_at_unix_ms: Some(terminal_unix_secs),
},
)
.await;
return Ok(None);
}
},
Err(err) => {
warn!(
event_name = "sync_execution_runtime_unavailable",
event_name = "chatgpt_web_image_execution_unavailable",
log_type = "ops",
trace_id = %trace_id,
request_id = %plan_request_id_for_log,
@@ -208,7 +245,7 @@ pub(crate) async fn execute_execution_runtime_sync(
model_name,
candidate_index = candidate_index.as_str(),
error = %err,
"gateway in-process sync execution unavailable"
"gateway ChatGPT-Web image execution unavailable"
);
let terminal_unix_secs = current_request_candidate_unix_ms();
record_local_request_candidate_status(
@@ -218,7 +255,7 @@ pub(crate) async fn execute_execution_runtime_sync(
SchedulerRequestCandidateStatusUpdate {
status: RequestCandidateStatus::Failed,
status_code: None,
error_type: Some("execution_runtime_unavailable".to_string()),
error_type: Some("chatgpt_web_image_execution_unavailable".to_string()),
error_message: Some(err.to_string()),
latency_ms: None,
started_at_unix_ms: Some(candidate_started_unix_secs),
@@ -275,11 +312,48 @@ pub(crate) async fn execute_execution_runtime_sync(
.trim()
.is_empty()
{
match DirectSyncExecutionRuntime::new().execute_sync(&plan).await {
Ok(result) => result,
match maybe_execute_chatgpt_web_image_sync(state, &plan, report_context.as_ref()).await
{
Ok(Some(result)) => result,
Ok(None) => match DirectSyncExecutionRuntime::new().execute_sync(&plan).await {
Ok(result) => result,
Err(err) => {
warn!(
event_name = "sync_execution_runtime_unavailable",
log_type = "ops",
trace_id = %trace_id,
request_id = %plan_request_id_for_log,
candidate_id = ?plan_candidate_id,
provider_name,
endpoint_id,
key_id,
model_name,
candidate_index = candidate_index.as_str(),
error = %err,
"gateway in-process sync execution unavailable"
);
let terminal_unix_secs = current_request_candidate_unix_ms();
record_local_request_candidate_status(
state,
&plan,
report_context.as_ref(),
SchedulerRequestCandidateStatusUpdate {
status: RequestCandidateStatus::Failed,
status_code: None,
error_type: Some("execution_runtime_unavailable".to_string()),
error_message: Some(err.to_string()),
latency_ms: None,
started_at_unix_ms: Some(candidate_started_unix_secs),
finished_at_unix_ms: Some(terminal_unix_secs),
},
)
.await;
return Ok(None);
}
},
Err(err) => {
warn!(
event_name = "sync_execution_runtime_unavailable",
event_name = "chatgpt_web_image_execution_unavailable",
log_type = "ops",
trace_id = %trace_id,
request_id = %plan_request_id_for_log,
@@ -290,7 +364,7 @@ pub(crate) async fn execute_execution_runtime_sync(
model_name,
candidate_index = candidate_index.as_str(),
error = %err,
"gateway in-process sync execution unavailable"
"gateway ChatGPT-Web image execution unavailable"
);
let terminal_unix_secs = current_request_candidate_unix_ms();
record_local_request_candidate_status(
@@ -300,7 +374,7 @@ pub(crate) async fn execute_execution_runtime_sync(
SchedulerRequestCandidateStatusUpdate {
status: RequestCandidateStatus::Failed,
status_code: None,
error_type: Some("execution_runtime_unavailable".to_string()),
error_type: Some("chatgpt_web_image_execution_unavailable".to_string()),
error_message: Some(err.to_string()),
latency_ms: None,
started_at_unix_ms: Some(candidate_started_unix_secs),

View File

@@ -6,7 +6,8 @@ use std::time::{Duration, Instant};
use aether_contracts::{
ExecutionPlan, ExecutionResult, ExecutionTelemetry, ProxySnapshot, ResolvedTransportProfile,
ResponseBody, EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER, EXECUTION_REQUEST_HTTP1_ONLY_HEADER,
ResponseBody, EXECUTION_REQUEST_ACCEPT_INVALID_CERTS_HEADER,
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;
@@ -141,6 +142,7 @@ pub(crate) struct DirectSyncExecutionRuntime;
struct ExecutionTransportControls {
follow_redirects: Option<bool>,
http1_only: bool,
accept_invalid_certs: bool,
}
pub(crate) enum DirectUpstreamResponse {
@@ -894,6 +896,9 @@ fn build_client(
builder,
transport_profile.map(|profile| profile.profile_id.as_str()),
);
if transport_controls.accept_invalid_certs {
builder = builder.danger_accept_invalid_certs(true);
}
if let Some(proxy_url) = resolve_proxy_url(proxy)? {
let proxy = reqwest::Proxy::all(&proxy_url)
.map_err(ExecutionRuntimeTransportError::InvalidProxy)?;
@@ -1025,6 +1030,7 @@ fn build_request_headers(
|| normalized_key == "content-encoding"
|| normalized_key == EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER
|| normalized_key == EXECUTION_REQUEST_HTTP1_ONLY_HEADER
|| normalized_key == EXECUTION_REQUEST_ACCEPT_INVALID_CERTS_HEADER
{
continue;
}
@@ -1058,6 +1064,12 @@ fn resolve_execution_transport_controls(
http1_only: execution_transport_header_value(headers, EXECUTION_REQUEST_HTTP1_ONLY_HEADER)
.and_then(|value| parse_execution_transport_bool(value))
.unwrap_or(false),
accept_invalid_certs: execution_transport_header_value(
headers,
EXECUTION_REQUEST_ACCEPT_INVALID_CERTS_HEADER,
)
.and_then(|value| parse_execution_transport_bool(value))
.unwrap_or(false),
}
}
@@ -1201,9 +1213,10 @@ mod tests {
use tokio::sync::watch;
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,
build_client, build_request_headers, execute_sync_plan,
record_manual_proxy_request_failure, record_manual_proxy_request_outcome,
record_manual_proxy_request_success, record_manual_proxy_stream_error,
resolve_execution_transport_controls, DirectSyncExecutionRuntime,
ExecutionRuntimeTransportError, ExecutionTransportControls,
};
use crate::constants::{
@@ -1285,6 +1298,27 @@ mod tests {
}
}
#[test]
fn direct_sync_execution_runtime_strips_accept_invalid_certs_control_header() {
let headers = BTreeMap::from([
("content-type".into(), "application/json".into()),
(
"x-aether-execution-accept-invalid-certs".into(),
"true".into(),
),
]);
let controls = resolve_execution_transport_controls(&headers);
assert!(controls.accept_invalid_certs);
let forwarded = build_request_headers(&headers, None, false)
.expect("headers should build after stripping internal controls");
assert!(forwarded.get("content-type").is_some());
assert!(forwarded
.get("x-aether-execution-accept-invalid-certs")
.is_none());
}
fn tunnel_proxy_snapshot(base_url: String) -> ProxySnapshot {
ProxySnapshot {
enabled: Some(true),