mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-05 00:47:48 +08:00
531 lines
18 KiB
Rust
531 lines
18 KiB
Rust
use std::collections::BTreeMap;
|
|
use std::sync::Arc;
|
|
|
|
use aether_contracts::ResolvedTransportProfile;
|
|
use serde_json::Value;
|
|
|
|
use crate::ai_serving::planner::candidate_preparation::{
|
|
prepare_header_authenticated_candidate, OauthPreparationContext,
|
|
};
|
|
use crate::ai_serving::planner::spec_metadata::local_openai_image_spec_metadata;
|
|
use crate::ai_serving::pure::normalize_openai_image_request_with_options;
|
|
use crate::ai_serving::transport::{
|
|
build_grok_browser_headers, build_grok_upstream_url, build_openai_image_headers,
|
|
build_openai_image_upstream_url, build_standard_provider_request_headers,
|
|
openai_image_transport_unsupported_reason, resolve_openai_image_auth, GrokHeaderInput,
|
|
ProviderOpenAiImageHeadersInput, StandardProviderRequestHeadersInput, GROK_CHAT_PATH,
|
|
};
|
|
use crate::ai_serving::{
|
|
apply_codex_openai_special_headers, build_chatgpt_web_image_request_body,
|
|
build_codex_openai_image_api_provider_request_body,
|
|
build_gemini_image_request_body_from_openai_image_request,
|
|
build_openai_image_api_provider_request_body, build_openai_image_provider_request_body,
|
|
default_model_for_openai_image_operation, normalize_openai_image_request,
|
|
request_conversion_direct_auth, CandidateFailureDiagnostic, GatewayProviderTransportSnapshot,
|
|
PlannerAppState, RequestConversionKind,
|
|
};
|
|
use crate::image_capabilities::openai_image_normalize_options_for_provider;
|
|
use crate::AppState;
|
|
|
|
use super::support::{
|
|
mark_skipped_local_openai_image_candidate,
|
|
mark_skipped_local_openai_image_candidate_with_failure_diagnostic,
|
|
LocalOpenAiImageCandidateAttempt, LocalOpenAiImageDecisionInput,
|
|
};
|
|
use super::LocalOpenAiImageSpec;
|
|
|
|
pub(super) use crate::ai_serving::resolve_requested_openai_image_model_for_request as resolve_requested_image_model_for_request;
|
|
|
|
pub(super) struct LocalOpenAiImageCandidatePayloadParts {
|
|
pub(super) transport: Arc<GatewayProviderTransportSnapshot>,
|
|
pub(super) auth_header: String,
|
|
pub(super) auth_value: String,
|
|
pub(super) requested_model: String,
|
|
pub(super) mapped_model: String,
|
|
pub(super) provider_api_format: String,
|
|
pub(super) provider_request_headers: BTreeMap<String, String>,
|
|
pub(super) provider_request_body: Value,
|
|
pub(super) upstream_url: String,
|
|
pub(super) input_summary: Value,
|
|
pub(super) transport_profile: Option<ResolvedTransportProfile>,
|
|
pub(super) upstream_is_stream: bool,
|
|
}
|
|
|
|
pub(super) async fn resolve_local_openai_image_candidate_payload_parts(
|
|
state: &AppState,
|
|
parts: &http::request::Parts,
|
|
body_json: &Value,
|
|
body_base64: Option<&str>,
|
|
trace_id: &str,
|
|
input: &LocalOpenAiImageDecisionInput,
|
|
attempt: &LocalOpenAiImageCandidateAttempt,
|
|
spec: LocalOpenAiImageSpec,
|
|
) -> Option<LocalOpenAiImageCandidatePayloadParts> {
|
|
let spec_metadata = local_openai_image_spec_metadata(spec);
|
|
let candidate = &attempt.eligible.candidate;
|
|
let transport = &attempt.eligible.transport;
|
|
let provider_api_format = attempt.eligible.provider_api_format.as_str();
|
|
let effective_headers = input.effective_headers(&parts.headers);
|
|
|
|
if provider_api_format == "gemini:generate_content" {
|
|
return resolve_local_openai_image_to_gemini_candidate_payload_parts(
|
|
state,
|
|
parts,
|
|
body_json,
|
|
body_base64,
|
|
trace_id,
|
|
input,
|
|
attempt,
|
|
spec,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
if let Some(skip_reason) =
|
|
openai_image_transport_unsupported_reason(transport, spec_metadata.api_format)
|
|
{
|
|
mark_skipped_local_openai_image_candidate(
|
|
state,
|
|
input,
|
|
trace_id,
|
|
candidate,
|
|
attempt.candidate_index,
|
|
&attempt.candidate_id,
|
|
skip_reason,
|
|
)
|
|
.await;
|
|
return None;
|
|
}
|
|
let prepared_candidate = match prepare_header_authenticated_candidate(
|
|
PlannerAppState::new(state),
|
|
transport,
|
|
candidate,
|
|
resolve_openai_image_auth(transport),
|
|
OauthPreparationContext {
|
|
trace_id,
|
|
api_format: spec_metadata.api_format,
|
|
operation: "openai_image_candidate_request",
|
|
},
|
|
)
|
|
.await
|
|
{
|
|
Ok(prepared) => prepared,
|
|
Err(skip_reason) => {
|
|
mark_skipped_local_openai_image_candidate(
|
|
state,
|
|
input,
|
|
trace_id,
|
|
candidate,
|
|
attempt.candidate_index,
|
|
&attempt.candidate_id,
|
|
skip_reason,
|
|
)
|
|
.await;
|
|
return None;
|
|
}
|
|
};
|
|
let auth_header = prepared_candidate.auth_header;
|
|
let auth_value = prepared_candidate.auth_value;
|
|
|
|
let normalized_request = normalize_openai_image_request_with_options(
|
|
parts,
|
|
body_json,
|
|
body_base64,
|
|
openai_image_normalize_options_for_provider(
|
|
&transport.provider.provider_type,
|
|
Some(prepared_candidate.mapped_model.as_str()),
|
|
),
|
|
);
|
|
let Some(normalized_request) = normalized_request else {
|
|
mark_skipped_local_openai_image_candidate_with_failure_diagnostic(
|
|
state,
|
|
input,
|
|
trace_id,
|
|
candidate,
|
|
attempt.candidate_index,
|
|
&attempt.candidate_id,
|
|
"provider_request_body_missing",
|
|
CandidateFailureDiagnostic::provider_request_body_missing(
|
|
spec_metadata.api_format,
|
|
spec_metadata.api_format,
|
|
"openai_image_request_normalize",
|
|
),
|
|
)
|
|
.await;
|
|
return None;
|
|
};
|
|
|
|
let is_chatgpt_web = transport
|
|
.provider
|
|
.provider_type
|
|
.trim()
|
|
.eq_ignore_ascii_case("chatgpt_web");
|
|
let is_grok = transport
|
|
.provider
|
|
.provider_type
|
|
.trim()
|
|
.eq_ignore_ascii_case("grok");
|
|
let is_codex = transport
|
|
.provider
|
|
.provider_type
|
|
.trim()
|
|
.eq_ignore_ascii_case("codex");
|
|
let transport_profile = crate::ai_serving::transport::resolve_transport_profile(transport);
|
|
let upstream_url = if is_chatgpt_web {
|
|
chatgpt_web_image_internal_url(&transport.endpoint.base_url)
|
|
} else if is_grok {
|
|
build_grok_upstream_url(transport, GROK_CHAT_PATH)
|
|
} else {
|
|
build_openai_image_upstream_url(transport, Some(parts.uri.path()), parts.uri.query())
|
|
};
|
|
let upstream_is_stream =
|
|
crate::ai_serving::planner::common::resolve_upstream_is_stream_for_provider(
|
|
transport.endpoint.config.as_ref(),
|
|
transport.provider.provider_type.as_str(),
|
|
spec_metadata.api_format,
|
|
spec_metadata.require_streaming && candidate.supports_streaming,
|
|
false,
|
|
);
|
|
let provider_request_body = if is_chatgpt_web {
|
|
Some(
|
|
match build_chatgpt_web_image_request_body(parts, body_json, body_base64) {
|
|
Ok(body) => body,
|
|
Err(err) => err.to_error_json(),
|
|
},
|
|
)
|
|
} else if is_codex {
|
|
build_codex_openai_image_api_provider_request_body(
|
|
&normalized_request,
|
|
Some(prepared_candidate.mapped_model.as_str()),
|
|
upstream_is_stream,
|
|
)
|
|
} else if is_grok {
|
|
Some(build_openai_image_provider_request_body(
|
|
&normalized_request,
|
|
))
|
|
} else {
|
|
build_openai_image_api_provider_request_body(
|
|
&normalized_request,
|
|
Some(prepared_candidate.mapped_model.as_str()),
|
|
upstream_is_stream,
|
|
)
|
|
};
|
|
let Some(provider_request_body) = provider_request_body else {
|
|
mark_skipped_local_openai_image_candidate_with_failure_diagnostic(
|
|
state,
|
|
input,
|
|
trace_id,
|
|
candidate,
|
|
attempt.candidate_index,
|
|
&attempt.candidate_id,
|
|
"provider_request_body_missing",
|
|
CandidateFailureDiagnostic::provider_request_body_missing(
|
|
spec_metadata.api_format,
|
|
spec_metadata.api_format,
|
|
"codex_openai_images_request_contract",
|
|
),
|
|
)
|
|
.await;
|
|
return None;
|
|
};
|
|
let Some(mut provider_request_headers) = (if is_grok {
|
|
build_grok_browser_headers(GrokHeaderInput {
|
|
transport,
|
|
transport_profile: transport_profile.as_ref(),
|
|
request_headers: Some(effective_headers),
|
|
content_type: "application/json",
|
|
accept: "*/*",
|
|
header_rules: transport.endpoint.header_rules.as_ref(),
|
|
provider_request_body: &provider_request_body,
|
|
original_request_body: body_json,
|
|
})
|
|
} else {
|
|
build_openai_image_headers(ProviderOpenAiImageHeadersInput {
|
|
transport,
|
|
headers: effective_headers,
|
|
auth_header: &auth_header,
|
|
auth_value: &auth_value,
|
|
accept: if is_codex {
|
|
None
|
|
} else if upstream_is_stream {
|
|
Some("text/event-stream")
|
|
} else {
|
|
Some("application/json")
|
|
},
|
|
header_rules: transport.endpoint.header_rules.as_ref(),
|
|
provider_request_body: &provider_request_body,
|
|
original_request_body: body_json,
|
|
})
|
|
}) else {
|
|
mark_skipped_local_openai_image_candidate_with_failure_diagnostic(
|
|
state,
|
|
input,
|
|
trace_id,
|
|
candidate,
|
|
attempt.candidate_index,
|
|
&attempt.candidate_id,
|
|
"transport_header_rules_apply_failed",
|
|
CandidateFailureDiagnostic::header_rules_apply_failed(
|
|
spec_metadata.api_format,
|
|
spec_metadata.api_format,
|
|
"openai_image_header_rules",
|
|
),
|
|
)
|
|
.await;
|
|
return None;
|
|
};
|
|
if is_chatgpt_web {
|
|
provider_request_headers.insert("x-aether-chatgpt-web-image".to_string(), "1".to_string());
|
|
} else if is_grok {
|
|
} else {
|
|
apply_codex_openai_special_headers(
|
|
&mut provider_request_headers,
|
|
&provider_request_body,
|
|
effective_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()
|
|
.unwrap_or_else(|| {
|
|
default_model_for_openai_image_operation(normalized_request.operation).to_string()
|
|
});
|
|
let mapped_model = provider_request_body
|
|
.get("model")
|
|
.and_then(Value::as_str)
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.unwrap_or_default()
|
|
.to_string();
|
|
|
|
let input_summary = if is_chatgpt_web || is_grok {
|
|
provider_request_body.clone()
|
|
} else {
|
|
normalized_request.summary_json
|
|
};
|
|
|
|
Some(LocalOpenAiImageCandidatePayloadParts {
|
|
transport: Arc::clone(transport),
|
|
auth_header,
|
|
auth_value,
|
|
requested_model,
|
|
mapped_model,
|
|
provider_api_format: spec_metadata.api_format.to_string(),
|
|
provider_request_headers,
|
|
provider_request_body,
|
|
upstream_url,
|
|
input_summary,
|
|
transport_profile,
|
|
upstream_is_stream,
|
|
})
|
|
}
|
|
|
|
async fn resolve_local_openai_image_to_gemini_candidate_payload_parts(
|
|
state: &AppState,
|
|
parts: &http::request::Parts,
|
|
body_json: &Value,
|
|
body_base64: Option<&str>,
|
|
trace_id: &str,
|
|
input: &LocalOpenAiImageDecisionInput,
|
|
attempt: &LocalOpenAiImageCandidateAttempt,
|
|
spec: LocalOpenAiImageSpec,
|
|
) -> Option<LocalOpenAiImageCandidatePayloadParts> {
|
|
let spec_metadata = local_openai_image_spec_metadata(spec);
|
|
let candidate = &attempt.eligible.candidate;
|
|
let transport = &attempt.eligible.transport;
|
|
let provider_api_format = "gemini:generate_content";
|
|
let effective_headers = input.effective_headers(&parts.headers);
|
|
|
|
let prepared_candidate = match prepare_header_authenticated_candidate(
|
|
PlannerAppState::new(state),
|
|
transport,
|
|
candidate,
|
|
request_conversion_direct_auth(transport, RequestConversionKind::ToGeminiStandard),
|
|
OauthPreparationContext {
|
|
trace_id,
|
|
api_format: provider_api_format,
|
|
operation: "openai_image_to_gemini_candidate_request",
|
|
},
|
|
)
|
|
.await
|
|
{
|
|
Ok(prepared) => prepared,
|
|
Err(skip_reason) => {
|
|
mark_skipped_local_openai_image_candidate(
|
|
state,
|
|
input,
|
|
trace_id,
|
|
candidate,
|
|
attempt.candidate_index,
|
|
&attempt.candidate_id,
|
|
skip_reason,
|
|
)
|
|
.await;
|
|
return None;
|
|
}
|
|
};
|
|
|
|
let Some(normalized_request) = normalize_openai_image_request(parts, body_json, body_base64)
|
|
else {
|
|
mark_skipped_local_openai_image_candidate_with_failure_diagnostic(
|
|
state,
|
|
input,
|
|
trace_id,
|
|
candidate,
|
|
attempt.candidate_index,
|
|
&attempt.candidate_id,
|
|
"provider_request_body_missing",
|
|
CandidateFailureDiagnostic::provider_request_body_missing(
|
|
spec_metadata.api_format,
|
|
provider_api_format,
|
|
"openai_image_request_normalize",
|
|
),
|
|
)
|
|
.await;
|
|
return None;
|
|
};
|
|
|
|
let Some(mut converted) = build_gemini_image_request_body_from_openai_image_request(
|
|
&normalized_request,
|
|
&prepared_candidate.mapped_model,
|
|
) else {
|
|
mark_skipped_local_openai_image_candidate_with_failure_diagnostic(
|
|
state,
|
|
input,
|
|
trace_id,
|
|
candidate,
|
|
attempt.candidate_index,
|
|
&attempt.candidate_id,
|
|
"provider_request_body_missing",
|
|
CandidateFailureDiagnostic::provider_request_body_missing(
|
|
spec_metadata.api_format,
|
|
provider_api_format,
|
|
"openai_image_to_gemini_request_body",
|
|
),
|
|
)
|
|
.await;
|
|
return None;
|
|
};
|
|
converted.body_json =
|
|
match crate::ai_serving::transport::apply_standard_provider_request_body_rules_with_request_headers(
|
|
converted.body_json,
|
|
transport.endpoint.body_rules.as_ref(),
|
|
body_json,
|
|
effective_headers,
|
|
) {
|
|
Some(body) => body,
|
|
None => {
|
|
mark_skipped_local_openai_image_candidate_with_failure_diagnostic(
|
|
state,
|
|
input,
|
|
trace_id,
|
|
candidate,
|
|
attempt.candidate_index,
|
|
&attempt.candidate_id,
|
|
"provider_request_body_missing",
|
|
CandidateFailureDiagnostic::provider_request_body_missing(
|
|
spec_metadata.api_format,
|
|
provider_api_format,
|
|
"openai_image_to_gemini_body_rules",
|
|
),
|
|
)
|
|
.await;
|
|
return None;
|
|
}
|
|
};
|
|
let upstream_is_stream =
|
|
crate::ai_serving::planner::common::resolve_upstream_is_stream_for_provider(
|
|
transport.endpoint.config.as_ref(),
|
|
transport.provider.provider_type.as_str(),
|
|
provider_api_format,
|
|
spec_metadata.require_streaming && candidate.supports_streaming,
|
|
false,
|
|
);
|
|
let Some(upstream_url) = crate::ai_serving::planner::standard::build_standard_upstream_url(
|
|
parts,
|
|
transport,
|
|
&converted.mapped_model,
|
|
provider_api_format,
|
|
upstream_is_stream,
|
|
Some(&converted.body_json),
|
|
) else {
|
|
mark_skipped_local_openai_image_candidate_with_failure_diagnostic(
|
|
state,
|
|
input,
|
|
trace_id,
|
|
candidate,
|
|
attempt.candidate_index,
|
|
&attempt.candidate_id,
|
|
"upstream_url_missing",
|
|
CandidateFailureDiagnostic::upstream_url_missing(
|
|
spec_metadata.api_format,
|
|
provider_api_format,
|
|
"openai_image_to_gemini_url",
|
|
),
|
|
)
|
|
.await;
|
|
return None;
|
|
};
|
|
let Some(resolved_headers) =
|
|
build_standard_provider_request_headers(StandardProviderRequestHeadersInput {
|
|
transport,
|
|
provider_api_format,
|
|
same_format: false,
|
|
headers: effective_headers,
|
|
auth_header: &prepared_candidate.auth_header,
|
|
auth_value: &prepared_candidate.auth_value,
|
|
extra_headers: &BTreeMap::new(),
|
|
header_rules: transport.endpoint.header_rules.as_ref(),
|
|
provider_request_body: &converted.body_json,
|
|
original_request_body: body_json,
|
|
upstream_is_stream,
|
|
})
|
|
else {
|
|
mark_skipped_local_openai_image_candidate_with_failure_diagnostic(
|
|
state,
|
|
input,
|
|
trace_id,
|
|
candidate,
|
|
attempt.candidate_index,
|
|
&attempt.candidate_id,
|
|
"transport_header_rules_apply_failed",
|
|
CandidateFailureDiagnostic::header_rules_apply_failed(
|
|
spec_metadata.api_format,
|
|
provider_api_format,
|
|
"openai_image_to_gemini_headers",
|
|
),
|
|
)
|
|
.await;
|
|
return None;
|
|
};
|
|
|
|
Some(LocalOpenAiImageCandidatePayloadParts {
|
|
transport: Arc::clone(transport),
|
|
auth_header: resolved_headers.auth_header,
|
|
auth_value: resolved_headers.auth_value,
|
|
requested_model: converted.requested_model,
|
|
mapped_model: converted.mapped_model,
|
|
provider_api_format: provider_api_format.to_string(),
|
|
provider_request_headers: resolved_headers.headers,
|
|
provider_request_body: converted.body_json,
|
|
upstream_url,
|
|
input_summary: converted.summary_json,
|
|
transport_profile: None,
|
|
upstream_is_stream,
|
|
})
|
|
}
|
|
|
|
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")
|
|
}
|