mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
feat(image): 接入 ChatGPT Web 生图反代
This commit is contained in:
@@ -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"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
2317
apps/aether-gateway/src/execution_runtime/chatgpt_web_image.rs
Normal file
2317
apps/aether-gateway/src/execution_runtime/chatgpt_web_image.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user