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

View File

@@ -453,7 +453,7 @@ fn resolve_transport_auth_type_for_endpoint_format(
fn provider_uses_bearer_like_oauth(provider_type: &str) -> bool {
matches!(
provider_type.trim().to_ascii_lowercase().as_str(),
"claude_code" | "gemini_cli" | "antigravity" | "kiro"
"claude_code" | "chatgpt_web" | "gemini_cli" | "antigravity" | "kiro"
)
}

View File

@@ -59,6 +59,26 @@ pub(super) async fn maybe_build_local_openai_image_decision_payload_for_candidat
extra_fields.insert("proxy".to_string(), proxy_value);
}
extra_fields.insert("image_request".to_string(), resolved.input_summary.clone());
if transport
.provider
.provider_type
.trim()
.eq_ignore_ascii_case("chatgpt_web")
{
extra_fields.insert(
"chatgpt_web_image".to_string(),
serde_json::Value::Bool(true),
);
extra_fields.insert(
"local_failover_policy".to_string(),
serde_json::json!({
"stop_status_codes": [400, 401, 403, 429, 500, 502, 503, 504],
"error_stop_patterns": [
{ "pattern": ".*" }
]
}),
);
}
let upstream_is_stream = resolved
.provider_request_body
.get("stream")

View File

@@ -14,9 +14,9 @@ use crate::ai_serving::transport::{
};
use crate::ai_serving::{
apply_codex_openai_responses_special_body_edits, apply_codex_openai_responses_special_headers,
build_openai_image_provider_request_body, default_model_for_openai_image_operation,
normalize_openai_image_request, CandidateFailureDiagnostic, GatewayProviderTransportSnapshot,
PlannerAppState,
build_chatgpt_web_image_request_body, build_openai_image_provider_request_body,
default_model_for_openai_image_operation, normalize_openai_image_request,
CandidateFailureDiagnostic, GatewayProviderTransportSnapshot, PlannerAppState,
};
use crate::AppState;
@@ -122,15 +122,33 @@ pub(super) async fn resolve_local_openai_image_candidate_payload_parts(
return None;
};
let upstream_url = build_openai_image_upstream_url(transport, parts.uri.query());
let mut provider_request_body = build_openai_image_provider_request_body(&normalized_request);
apply_codex_openai_responses_special_body_edits(
&mut provider_request_body,
transport.provider.provider_type.as_str(),
spec_metadata.api_format,
transport.endpoint.body_rules.as_ref(),
Some(candidate.key_id.as_str()),
);
let is_chatgpt_web = transport
.provider
.provider_type
.trim()
.eq_ignore_ascii_case("chatgpt_web");
let upstream_url = if is_chatgpt_web {
chatgpt_web_image_internal_url(&transport.endpoint.base_url)
} else {
build_openai_image_upstream_url(transport, parts.uri.query())
};
let mut provider_request_body = if is_chatgpt_web {
match build_chatgpt_web_image_request_body(parts, body_json, body_base64) {
Ok(body) => body,
Err(err) => err.to_error_json(),
}
} else {
build_openai_image_provider_request_body(&normalized_request)
};
if !is_chatgpt_web {
apply_codex_openai_responses_special_body_edits(
&mut provider_request_body,
transport.provider.provider_type.as_str(),
spec_metadata.api_format,
transport.endpoint.body_rules.as_ref(),
Some(candidate.key_id.as_str()),
);
}
let Some(mut provider_request_headers) =
build_openai_image_headers(ProviderOpenAiImageHeadersInput {
@@ -159,15 +177,19 @@ pub(super) async fn resolve_local_openai_image_candidate_payload_parts(
.await;
return None;
};
apply_codex_openai_responses_special_headers(
&mut provider_request_headers,
&provider_request_body,
&parts.headers,
transport.provider.provider_type.as_str(),
spec_metadata.api_format,
Some(trace_id),
transport.key.decrypted_auth_config.as_deref(),
);
if is_chatgpt_web {
provider_request_headers.insert("x-aether-chatgpt-web-image".to_string(), "1".to_string());
} else {
apply_codex_openai_responses_special_headers(
&mut provider_request_headers,
&provider_request_body,
&parts.headers,
transport.provider.provider_type.as_str(),
spec_metadata.api_format,
Some(trace_id),
transport.key.decrypted_auth_config.as_deref(),
);
}
let requested_model = normalized_request
.requested_model
.clone()
@@ -182,6 +204,12 @@ pub(super) async fn resolve_local_openai_image_candidate_payload_parts(
.unwrap_or_default()
.to_string();
let input_summary = if is_chatgpt_web {
provider_request_body.clone()
} else {
normalized_request.summary_json
};
Some(LocalOpenAiImageCandidatePayloadParts {
transport: Arc::clone(transport),
auth_header,
@@ -191,6 +219,16 @@ pub(super) async fn resolve_local_openai_image_candidate_payload_parts(
provider_request_headers,
provider_request_body,
upstream_url,
input_summary: normalized_request.summary_json,
input_summary,
})
}
fn chatgpt_web_image_internal_url(base_url: &str) -> String {
let base_url = base_url.trim().trim_end_matches('/');
let base_url = if base_url.is_empty() {
"https://chatgpt.com"
} else {
base_url
};
format!("{base_url}/__aether/chatgpt-web-image")
}

View File

@@ -5,8 +5,8 @@ pub(crate) use aether_ai_formats::api::{
api_format_alias_matches, apply_codex_openai_responses_special_body_edits,
apply_codex_openai_responses_special_headers, apply_model_directive_mapping_patch,
apply_model_directive_overrides_from_model, apply_model_directive_overrides_from_request,
apply_openai_responses_compact_special_body_edits, build_core_error_body_for_client_format,
build_cross_format_openai_chat_request_body,
apply_openai_responses_compact_special_body_edits, build_chatgpt_web_image_request_body,
build_core_error_body_for_client_format, build_cross_format_openai_chat_request_body,
build_cross_format_openai_chat_request_body_with_model_directives,
build_cross_format_openai_responses_request_body,
build_cross_format_openai_responses_request_body_with_model_directives,
@@ -75,20 +75,20 @@ pub(crate) use aether_ai_formats::api::{
supports_stream_execution_decision_kind, supports_sync_execution_decision_kind,
sync_chat_response_conversion_kind, sync_cli_response_conversion_kind,
transform_provider_private_stream_line, value_as_u64, AiControlPlanRequest,
AiSurfaceFinalizeError, AiSurfaceStreamRewriter, CanonicalStreamFrame, ClaudeClientEmitter,
ClaudeProviderState, ExecutionRuntimeAuthContext, FinalizeStreamRewriteMode, FormatContext,
GeminiClientEmitter, GeminiProviderState, KiroToClaudeCliStreamState, LocalCoreSyncErrorKind,
LocalGeminiFilesSpec, LocalOpenAiImageSpec, LocalOpenAiResponsesSpec,
LocalSameFormatProviderFamily, LocalSameFormatProviderSpec, LocalStandardSourceFamily,
LocalStandardSourceMode, LocalStandardSpec, LocalSyncReportParts, LocalVideoCreateFamily,
LocalVideoCreateSpec, NormalizedOpenAiImageRequest, OpenAIChatClientEmitter,
OpenAIChatProviderState, OpenAIResponsesClientEmitter, OpenAIResponsesProviderState,
OpenAiImageOperation, OpenAiImageResponseFormat, OpenAiImageStreamState,
OpenAiImageSyncFinalizeProduct, ProviderAdaptationDescriptor, ProviderAdaptationSurface,
ProviderPrivateStreamNormalizer, RequestConversionKind, StandardCrossFormatSyncProduct,
StandardSyncFinalizeNormalizedProduct, StreamingStandardFormatMatrix,
SyncChatResponseConversionKind, SyncCliResponseConversionKind, SyncToStreamBridgeOutcome,
ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME, CLAUDE_CHAT_STREAM_PLAN_KIND,
AiSurfaceFinalizeError, AiSurfaceStreamRewriter, CanonicalStreamFrame,
ChatGptWebImageRequestError, ClaudeClientEmitter, ClaudeProviderState,
ExecutionRuntimeAuthContext, FinalizeStreamRewriteMode, FormatContext, GeminiClientEmitter,
GeminiProviderState, KiroToClaudeCliStreamState, LocalCoreSyncErrorKind, LocalGeminiFilesSpec,
LocalOpenAiImageSpec, LocalOpenAiResponsesSpec, LocalSameFormatProviderFamily,
LocalSameFormatProviderSpec, LocalStandardSourceFamily, LocalStandardSourceMode,
LocalStandardSpec, LocalSyncReportParts, LocalVideoCreateFamily, LocalVideoCreateSpec,
NormalizedOpenAiImageRequest, OpenAIChatClientEmitter, OpenAIChatProviderState,
OpenAIResponsesClientEmitter, OpenAIResponsesProviderState, OpenAiImageOperation,
OpenAiImageResponseFormat, OpenAiImageStreamState, OpenAiImageSyncFinalizeProduct,
ProviderAdaptationDescriptor, ProviderAdaptationSurface, ProviderPrivateStreamNormalizer,
RequestConversionKind, StandardCrossFormatSyncProduct, StandardSyncFinalizeNormalizedProduct,
StreamingStandardFormatMatrix, SyncChatResponseConversionKind, SyncCliResponseConversionKind,
SyncToStreamBridgeOutcome, ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME, CLAUDE_CHAT_STREAM_PLAN_KIND,
CLAUDE_CHAT_STREAM_SUCCESS_REPORT_KIND, CLAUDE_CHAT_SYNC_ERROR_REPORT_KIND,
CLAUDE_CHAT_SYNC_FINALIZE_REPORT_KIND, CLAUDE_CHAT_SYNC_PLAN_KIND,
CLAUDE_CHAT_SYNC_SUCCESS_REPORT_KIND, CLAUDE_CLI_STREAM_PLAN_KIND,

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),

View File

@@ -22,6 +22,16 @@ pub(crate) fn provider_oauth_runtime_endpoint_for_provider(
&& crate::ai_serving::is_openai_responses_format(&endpoint.api_format)
})
.cloned(),
"chatgpt_web" => endpoints
.iter()
.find(|endpoint| {
endpoint.is_active
&& endpoint
.api_format
.trim()
.eq_ignore_ascii_case("openai:image")
})
.cloned(),
"antigravity" => endpoints
.iter()
.find(|endpoint| {

View File

@@ -3,10 +3,10 @@ use std::collections::BTreeSet;
pub(crate) fn normalize_provider_type_input(value: &str) -> Result<String, String> {
let normalized = value.trim().to_ascii_lowercase();
match normalized.as_str() {
"custom" | "claude_code" | "kiro" | "codex" | "gemini_cli" | "antigravity"
| "vertex_ai" => Ok(normalized),
"custom" | "claude_code" | "kiro" | "codex" | "chatgpt_web" | "gemini_cli"
| "antigravity" | "vertex_ai" => Ok(normalized),
_ => Err(
"provider_type 仅支持 custom / claude_code / kiro / codex / gemini_cli / antigravity / vertex_ai"
"provider_type 仅支持 custom / claude_code / kiro / codex / chatgpt_web / gemini_cli / antigravity / vertex_ai"
.to_string(),
),
}
@@ -180,7 +180,8 @@ fn normalize_json_like_object(
mod tests {
use super::{
normalize_api_format_json_object_keys, normalize_api_format_list, normalize_auth_type,
normalize_auth_type_by_format, normalize_pool_advanced_config, validate_vertex_api_formats,
normalize_auth_type_by_format, normalize_pool_advanced_config,
normalize_provider_type_input, validate_vertex_api_formats,
};
use serde_json::json;
@@ -212,6 +213,14 @@ mod tests {
);
}
#[test]
fn normalize_provider_type_supports_chatgpt_web() {
assert_eq!(
normalize_provider_type_input(" ChatGPT_Web ").expect("type should normalize"),
"chatgpt_web"
);
}
#[test]
fn normalize_api_format_list_dedupes_canonical_formats() {
assert_eq!(

View File

@@ -102,7 +102,7 @@ fn key_has_auth_type_overrides(key: &StoredProviderCatalogKey) -> bool {
fn provider_uses_bearer_oauth_runtime(provider_type: &str) -> bool {
matches!(
provider_type.trim().to_ascii_lowercase().as_str(),
"claude_code" | "codex" | "gemini_cli" | "antigravity" | "kiro"
"claude_code" | "codex" | "chatgpt_web" | "gemini_cli" | "antigravity" | "kiro"
)
}
@@ -278,6 +278,21 @@ mod tests {
);
}
#[test]
fn recognizes_chatgpt_web_oauth_as_bearer_runtime() {
let semantics = provider_key_auth_semantics(&sample_key("oauth"), "chatgpt_web");
assert!(semantics.oauth_managed());
assert_eq!(
semantics.credential_kind(),
ProviderKeyCredentialKind::OAuthSession
);
assert_eq!(
semantics.runtime_auth_kind(),
ProviderKeyRuntimeAuthKind::Bearer
);
}
#[test]
fn recognizes_legacy_kiro_bearer_key_with_auth_config_as_oauth_managed() {
let mut key = sample_key("bearer");

View File

@@ -523,3 +523,334 @@ async fn gateway_executes_codex_image_sync_via_local_decision_gate_after_oauth_r
execution_runtime_handle.abort();
refresh_handle.abort();
}
#[tokio::test]
async fn gateway_plans_chatgpt_web_image_sync_with_internal_web_executor_url() {
#[derive(Debug, Clone)]
struct SeenExecutionRuntimeSyncRequest {
trace_id: String,
url: String,
marker: String,
authorization: String,
operation: String,
model: String,
web_model: String,
prompt: String,
size: String,
ratio: String,
}
fn hash_api_key(value: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(value.as_bytes());
format!("{:x}", hasher.finalize())
}
fn sample_auth_snapshot(api_key_id: &str, user_id: &str) -> StoredAuthApiKeySnapshot {
StoredAuthApiKeySnapshot::new(
user_id.to_string(),
"alice".to_string(),
Some("alice@example.com".to_string()),
"user".to_string(),
"local".to_string(),
true,
false,
Some(serde_json::json!(["openai", "chatgpt_web"])),
Some(serde_json::json!(["openai:image"])),
Some(serde_json::json!(["gpt-image-2"])),
api_key_id.to_string(),
Some("default".to_string()),
true,
false,
false,
Some(60),
Some(5),
Some(4_102_444_800_i64),
Some(serde_json::json!(["openai", "chatgpt_web"])),
Some(serde_json::json!(["openai:image"])),
Some(serde_json::json!(["gpt-image-2"])),
)
.expect("auth snapshot should build")
}
fn sample_candidate_row() -> StoredMinimalCandidateSelectionRow {
StoredMinimalCandidateSelectionRow {
provider_id: "provider-chatgpt-web-image-plan-1".to_string(),
provider_name: "ChatGPT Web".to_string(),
provider_type: "chatgpt_web".to_string(),
provider_priority: 10,
provider_is_active: true,
endpoint_id: "endpoint-chatgpt-web-image-plan-1".to_string(),
endpoint_api_format: "openai:image".to_string(),
endpoint_api_family: Some("openai".to_string()),
endpoint_kind: Some("image".to_string()),
endpoint_is_active: true,
key_id: "key-chatgpt-web-image-plan-1".to_string(),
key_name: "manual bearer".to_string(),
key_auth_type: "bearer".to_string(),
key_is_active: true,
key_api_formats: Some(vec!["openai:image".to_string()]),
key_allowed_models: None,
key_capabilities: None,
key_internal_priority: 5,
key_global_priority_by_format: Some(serde_json::json!({"openai:image": 1})),
model_id: "model-chatgpt-web-image-plan-1".to_string(),
global_model_id: "global-model-chatgpt-web-image-plan-1".to_string(),
global_model_name: "gpt-image-2".to_string(),
global_model_mappings: None,
global_model_supports_streaming: Some(true),
model_provider_model_name: "gpt-image-2".to_string(),
model_provider_model_mappings: Some(vec![StoredProviderModelMapping {
name: "gpt-image-2".to_string(),
priority: 1,
api_formats: Some(vec!["openai:image".to_string()]),
}]),
model_supports_streaming: Some(true),
model_is_active: true,
model_is_available: true,
}
}
fn sample_provider_catalog_provider() -> StoredProviderCatalogProvider {
StoredProviderCatalogProvider::new(
"provider-chatgpt-web-image-plan-1".to_string(),
"ChatGPT Web".to_string(),
Some("https://chatgpt.com".to_string()),
"chatgpt_web".to_string(),
)
.expect("provider should build")
.with_transport_fields(
true,
false,
false,
None,
Some(2),
None,
Some(20.0),
None,
None,
)
}
fn sample_provider_catalog_endpoint() -> StoredProviderCatalogEndpoint {
StoredProviderCatalogEndpoint::new(
"endpoint-chatgpt-web-image-plan-1".to_string(),
"provider-chatgpt-web-image-plan-1".to_string(),
"openai:image".to_string(),
Some("openai".to_string()),
Some("image".to_string()),
true,
)
.expect("endpoint should build")
.with_transport_fields(
"https://chatgpt.com".to_string(),
None,
None,
Some(2),
None,
None,
None,
None,
)
.expect("endpoint transport should build")
}
fn sample_provider_catalog_key() -> StoredProviderCatalogKey {
StoredProviderCatalogKey::new(
"key-chatgpt-web-image-plan-1".to_string(),
"provider-chatgpt-web-image-plan-1".to_string(),
"manual bearer".to_string(),
"bearer".to_string(),
None,
true,
)
.expect("key should build")
.with_transport_fields(
Some(serde_json::json!(["openai:image"])),
encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, "chatgpt-web-access-token")
.expect("access token should encrypt"),
None,
None,
Some(serde_json::json!({"openai:image": 1})),
None,
None,
None,
None,
)
.expect("key transport should build")
}
let seen_execution_runtime = Arc::new(Mutex::new(None::<SeenExecutionRuntimeSyncRequest>));
let seen_execution_runtime_clone = Arc::clone(&seen_execution_runtime);
let execution_runtime = Router::new().route(
"/v1/execute/sync",
any(move |request: Request| {
let seen_execution_runtime_inner = Arc::clone(&seen_execution_runtime_clone);
async move {
let (parts, body) = request.into_parts();
let raw_body = to_bytes(body, usize::MAX).await.expect("body should read");
let payload: serde_json::Value = serde_json::from_slice(&raw_body)
.expect("execution runtime payload should parse");
let body = payload
.get("body")
.and_then(|value| value.get("json_body"))
.cloned()
.unwrap_or_else(|| json!({}));
*seen_execution_runtime_inner
.lock()
.expect("mutex should lock") = Some(SeenExecutionRuntimeSyncRequest {
trace_id: parts
.headers
.get(TRACE_ID_HEADER)
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_string(),
url: payload
.get("url")
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
marker: payload
.get("headers")
.and_then(|value| value.get("x-aether-chatgpt-web-image"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
authorization: payload
.get("headers")
.and_then(|value| value.get("authorization"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
operation: body
.get("operation")
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
model: body
.get("model")
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
web_model: body
.get("web_model")
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
prompt: body
.get("prompt")
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
size: body
.get("size")
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
ratio: body
.get("ratio")
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
});
Json(json!({
"request_id": "trace-chatgpt-web-image-plan-123",
"status_code": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": {
"body_bytes_b64": base64::engine::general_purpose::STANDARD.encode(
concat!(
"data: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"id\":\"ig_chatgpt_web_123\",\"type\":\"image_generation_call\",\"output_format\":\"png\",\"result\":\"aGVsbG8=\"}}\n\n",
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_chatgpt_web_123\",\"object\":\"response\",\"model\":\"gpt-image-2\",\"status\":\"completed\",\"output\":[]}}\n\n",
"data: [DONE]\n\n"
)
)
},
"telemetry": {
"elapsed_ms": 41
}
}))
}
}),
);
let client_api_key = "sk-client-chatgpt-web-image-plan";
let auth_repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![(
Some(hash_api_key(client_api_key)),
sample_auth_snapshot(
"key-chatgpt-web-image-client-123",
"user-chatgpt-web-image-client-123",
),
)]));
let candidate_selection_repository =
Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(vec![
sample_candidate_row(),
]));
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider_catalog_provider()],
vec![sample_provider_catalog_endpoint()],
vec![sample_provider_catalog_key()],
));
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
let gateway_state = build_state_with_execution_runtime_override(execution_runtime_url)
.with_data_state_for_tests(
crate::data::GatewayDataState::with_auth_candidate_selection_provider_catalog_and_request_candidate_repository_for_tests(
auth_repository,
candidate_selection_repository,
provider_catalog_repository,
Arc::new(InMemoryRequestCandidateRepository::default()),
DEVELOPMENT_ENCRYPTION_KEY,
),
);
let gateway = build_router_with_state(gateway_state);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.post(format!("{gateway_url}/v1/images/generations"))
.header(http::header::CONTENT_TYPE, "application/json")
.header(
http::header::AUTHORIZATION,
format!("Bearer {client_api_key}"),
)
.header(TRACE_ID_HEADER, "trace-chatgpt-web-image-plan-123")
.body("{\"model\":\"gpt-image-2\",\"prompt\":\"生成一张测试图\",\"size\":\"1024x1024\",\"response_format\":\"b64_json\"}")
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let response_json: serde_json::Value = response.json().await.expect("body should parse");
assert_eq!(response_json["data"][0]["b64_json"], "aGVsbG8=");
let seen_execution_runtime_request = seen_execution_runtime
.lock()
.expect("mutex should lock")
.clone()
.expect("execution runtime sync should be captured");
assert_eq!(
seen_execution_runtime_request.trace_id,
"trace-chatgpt-web-image-plan-123"
);
assert_eq!(
seen_execution_runtime_request.url,
"https://chatgpt.com/__aether/chatgpt-web-image"
);
assert!(!seen_execution_runtime_request.url.contains("/v1/responses"));
assert_eq!(seen_execution_runtime_request.marker, "1");
assert_eq!(
seen_execution_runtime_request.authorization,
"Bearer chatgpt-web-access-token"
);
assert_eq!(seen_execution_runtime_request.operation, "generate");
assert_eq!(seen_execution_runtime_request.model, "gpt-image-2");
assert_eq!(seen_execution_runtime_request.web_model, "gpt-5-5-thinking");
assert_eq!(seen_execution_runtime_request.prompt, "生成一张测试图");
assert_eq!(seen_execution_runtime_request.size, "1024x1024");
assert_eq!(seen_execution_runtime_request.ratio, "1:1");
gateway_handle.abort();
execution_runtime_handle.abort();
}

View File

@@ -176,11 +176,12 @@ async fn gateway_handles_admin_provider_oauth_supported_types_locally_with_trust
assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
let items = payload.as_array().expect("items should be array");
assert_eq!(items.len(), 4);
assert_eq!(items.len(), 5);
assert_eq!(items[0]["provider_type"], "claude_code");
assert_eq!(items[1]["provider_type"], "codex");
assert_eq!(items[2]["provider_type"], "gemini_cli");
assert_eq!(items[3]["provider_type"], "antigravity");
assert_eq!(items[2]["provider_type"], "chatgpt_web");
assert_eq!(items[3]["provider_type"], "gemini_cli");
assert_eq!(items[4]["provider_type"], "antigravity");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();