Files
Aether/apps/aether-gateway/src/execution_runtime/chatgpt_web_image.rs
T

5777 lines
202 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::collections::{BTreeMap, BTreeSet};
use std::io::Error as IoError;
use std::net::{IpAddr, SocketAddr};
use std::time::{Duration, Instant};
use aether_admin::provider::quota::{
parse_chatgpt_web_conversation_init_response, quota_refresh_success_invalid_state,
};
use aether_admin::provider::redaction::admin_provider_metadata_bucket_safe_json;
use aether_contracts::{
ExecutionPlan, ExecutionResult, ExecutionStreamTerminalSummary, ExecutionTelemetry,
ExecutionTimeouts, ProxySnapshot, RequestBody, ResolvedTransportProfile, ResponseBody,
StreamFrame, StreamFramePayload, StreamFrameType, TRANSPORT_BACKEND_BROWSER_WREQ,
TRANSPORT_HTTP_MODE_AUTO, TRANSPORT_POOL_SCOPE_KEY,
};
use aether_data_contracts::repository::provider_catalog::ProviderCatalogKeyRuntimeMetadataUpdate;
use aether_provider_pool::{
build_chatgpt_web_pool_quota_request, normalize_chatgpt_web_image_quota_limit,
ProviderPoolQuotaRequestSpec,
};
use axum::body::Bytes;
use base64::Engine as _;
use chrono::{FixedOffset, Utc};
use futures_util::stream::{self, BoxStream};
use futures_util::StreamExt;
use serde_json::{json, Map, Value};
use tracing::{debug, warn};
use uuid::Uuid;
use crate::ai_serving::api::StreamingStandardTerminalObserver;
use crate::clock::current_unix_secs;
use crate::execution_runtime::ndjson::encode_stream_frame_ndjson;
use crate::execution_runtime::transport::{
decode_base64_body_with_limit, format_upstream_request_error, json_value_fits_serialized_limit,
maximum_base64_len_for_decoded_limit, safe_transport_error_message,
serialize_json_body_with_limit, with_non_stream_total_timeout, DirectSyncExecutionRuntime,
ExecutionRuntimeTransportError,
};
use crate::handlers::shared::{
sync_provider_key_oauth_status_snapshot, sync_provider_key_quota_status_snapshot,
};
use crate::AppState;
const CHATGPT_WEB_INTERNAL_HEADER: &str = "x-aether-chatgpt-web-image";
const CHATGPT_WEB_DEFAULT_BASE_URL: &str = "https://chatgpt.com";
use aether_provider_transport::client_identity::CHATGPT_WEB_BROWSER_PROFILE;
use aether_provider_transport::client_identity::{
CHATGPT_WEB_BUILD_NUMBER, CHATGPT_WEB_CLIENT_VERSION, CHATGPT_WEB_SEC_CH_UA,
CHATGPT_WEB_USER_AGENT,
};
const CHATGPT_WEB_QUOTA_REFRESH_TIMEOUT_MS: u64 = 30_000;
const CHATGPT_WEB_QUOTA_REFRESH_PROXY_TIMEOUT_MS: u64 = 60_000;
const RUNTIME_METADATA_CAS_MAX_ATTEMPTS: usize = 16;
const GPT_IMAGE2_TOKEN_MIN_PIXELS: u64 = 655_360;
const GPT_IMAGE2_TOKEN_MAX_PIXELS: u64 = 8_294_400;
const GPT_IMAGE2_TOKEN_MAX_EDGE: u64 = 3_840;
const GPT_IMAGE2_TOKEN_MAX_ASPECT_RATIO: u64 = 3;
const GPT_IMAGE2_PARTIAL_IMAGE_OUTPUT_TOKENS: u64 = 100;
const CHATGPT_WEB_IMAGE_DOWNLOAD_MAX_REDIRECTS: usize = 10;
// A generated SSE response contains the image's base64 text plus JSON/event
// framing. Keep its decoded envelope bounded independently from the raw image
// limit, while retaining support for a raw image up to the default 64 MiB cap.
const CHATGPT_WEB_IMAGE_SSE_WRAPPER_OVERHEAD_BYTES: usize = 256 * 1024;
const CHATGPT_WEB_IMAGE_SSE_HARD_MAX_BYTES: usize = 128 * 1024 * 1024;
const CHATGPT_WEB_IMAGE_STREAM_CHUNK_BYTES: usize = 1024 * 1024;
const CHATGPT_WEB_IMAGE_PUBLIC_CONNECT_TIMEOUT_MS: u64 = 10_000;
const CHATGPT_WEB_IMAGE_PUBLIC_READ_TIMEOUT_MS: u64 = 30_000;
const CHATGPT_WEB_IMAGE_PUBLIC_TOTAL_TIMEOUT_MS: u64 = 300_000;
const CHATGPT_WEB_OPAQUE_ID_MAX_BYTES: usize = 256;
const CHATGPT_WEB_IMAGE_MAX_UPLOAD_URL_BYTES: usize = 64 * 1024;
const CHATGPT_WEB_IMAGE_UPLOAD_RESPONSE_LIMIT_BYTES: usize = 64 * 1024;
const CHATGPT_WEB_IMAGE_MAX_PROMPT_BYTES: usize = 32 * 1024;
const CHATGPT_WEB_IMAGE_MAX_MODEL_BYTES: usize = 256;
const CHATGPT_WEB_IMAGE_MAX_OPTION_BYTES: usize = 128;
const CHATGPT_WEB_IMAGE_MAX_EXTERNAL_URL_BYTES: usize = 64 * 1024;
const CHATGPT_WEB_IMAGE_MAX_INPUT_IMAGES: usize = 16;
const CHATGPT_WEB_IMAGE_MAX_DIMENSION: u32 = 16_384;
// Values in a provider SSE/poll response are merged across the initial
// response and up to 24 follow-up polls. Keep the retained candidate set
// bounded independently of the per-response body limit so a peer cannot make
// the gateway grow memory over the lifetime of one request.
const CHATGPT_WEB_IMAGE_SUMMARY_MAX_ITEMS: usize = 256;
const CHATGPT_WEB_IMAGE_SUMMARY_MAX_DIRECT_URLS: usize = 16;
const CHATGPT_WEB_IMAGE_SUMMARY_MAX_ID_BYTES: usize = 1024 * 1024;
const CHATGPT_WEB_IMAGE_SUMMARY_MAX_TEXT_BYTES: usize = 64 * 1024;
pub(crate) struct ChatGptWebImageStream {
pub(crate) frame_stream: BoxStream<'static, Result<Bytes, IoError>>,
pub(crate) report_context: Option<Value>,
}
#[derive(Debug, Clone)]
struct WebFingerprint {
user_agent: &'static str,
device_id: String,
session_id: String,
}
#[derive(Debug, Clone, Default)]
struct WebRequirement {
token: String,
proof_token: Option<String>,
so_token: Option<String>,
}
#[derive(Debug, Clone)]
struct WebUploadMeta {
file_id: String,
library_file_id: Option<String>,
file_name: String,
file_size: usize,
mime: String,
width: Option<u32>,
height: Option<u32>,
}
#[derive(Debug, Clone, Default)]
struct WebImageSseSummary {
conversation_id: Option<String>,
file_ids: Vec<String>,
sediment_ids: Vec<String>,
direct_urls: Vec<String>,
failure: Option<Value>,
last_text: Option<String>,
}
#[derive(Debug, Clone, Copy)]
enum WebImageSummaryCollection {
FileId,
SedimentId,
DirectUrl,
}
impl WebImageSseSummary {
fn retained_item_count(&self) -> usize {
self.file_ids
.len()
.saturating_add(self.sediment_ids.len())
.saturating_add(self.direct_urls.len())
}
fn retained_value_bytes(&self) -> usize {
saturating_string_bytes(&self.file_ids)
.saturating_add(saturating_string_bytes(&self.sediment_ids))
.saturating_add(saturating_string_bytes(&self.direct_urls))
}
fn add_values<I>(&mut self, collection: WebImageSummaryCollection, incoming: I)
where
I: IntoIterator<Item = String>,
{
for value in incoming {
self.add_value(collection, value);
}
}
fn add_value(&mut self, collection: WebImageSummaryCollection, value: String) {
if value.is_empty() {
return;
}
let (max_items, collection_budget) = match collection {
WebImageSummaryCollection::FileId | WebImageSummaryCollection::SedimentId => (
CHATGPT_WEB_IMAGE_SUMMARY_MAX_ITEMS,
CHATGPT_WEB_IMAGE_SUMMARY_MAX_ID_BYTES,
),
WebImageSummaryCollection::DirectUrl if is_data_image_reference(&value) => {
(4, chatgpt_web_image_sse_envelope_limit_bytes())
}
WebImageSummaryCollection::DirectUrl => (
CHATGPT_WEB_IMAGE_SUMMARY_MAX_DIRECT_URLS,
CHATGPT_WEB_IMAGE_MAX_EXTERNAL_URL_BYTES,
),
};
// Reject an over-sized value before it can become part of the retained
// set. The caller may have had to materialize it to parse a JSON
// field, but this prevents repeated poll responses from accumulating
// it and bounds the final synthetic SSE envelope.
if value.len() > collection_budget
|| self.retained_item_count() >= CHATGPT_WEB_IMAGE_SUMMARY_MAX_ITEMS
{
return;
}
let values = match collection {
WebImageSummaryCollection::FileId => &self.file_ids,
WebImageSummaryCollection::SedimentId => &self.sediment_ids,
WebImageSummaryCollection::DirectUrl => &self.direct_urls,
};
if values.len() >= max_items || values.iter().any(|existing| existing == &value) {
return;
}
let collection_bytes = match collection {
WebImageSummaryCollection::FileId => saturating_string_bytes(&self.file_ids),
WebImageSummaryCollection::SedimentId => saturating_string_bytes(&self.sediment_ids),
WebImageSummaryCollection::DirectUrl => saturating_string_bytes(&self.direct_urls),
};
let total_budget = chatgpt_web_image_sse_envelope_limit_bytes()
.saturating_add(CHATGPT_WEB_IMAGE_SUMMARY_MAX_ID_BYTES);
if value.len() > collection_budget.saturating_sub(collection_bytes)
|| value.len() > total_budget.saturating_sub(self.retained_value_bytes())
{
return;
}
match collection {
WebImageSummaryCollection::FileId => self.file_ids.push(value),
WebImageSummaryCollection::SedimentId => self.sediment_ids.push(value),
WebImageSummaryCollection::DirectUrl => self.direct_urls.push(value),
}
}
}
fn saturating_string_bytes(values: &[String]) -> usize {
values
.iter()
.fold(0usize, |total, value| total.saturating_add(value.len()))
}
fn is_data_image_reference(value: &str) -> bool {
value
.get(..11)
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("data:image/"))
}
#[derive(Debug, Clone)]
struct DownloadedImage {
b64_json: String,
mime: String,
width: Option<u32>,
height: Option<u32>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WebImageDownloadTrust {
UntrustedInput,
ProviderOutput,
}
struct WebImageHttpPayload {
data: Vec<u8>,
content_type: Option<String>,
}
pub(crate) async fn maybe_execute_chatgpt_web_image_sync(
state: &AppState,
plan: &ExecutionPlan,
report_context: Option<&Value>,
) -> Result<Option<ExecutionResult>, ExecutionRuntimeTransportError> {
if !is_chatgpt_web_image_plan(plan, report_context) {
return Ok(None);
}
with_non_stream_total_timeout(plan, async move {
let started_at = Instant::now();
let result = match execute_chatgpt_web_image(state, plan, report_context, started_at).await
{
Ok(result) => result,
Err(ExecutionRuntimeTransportError::UpstreamHttpStatus {
status_code,
message,
}) => chatgpt_web_http_error_execution_result(
plan,
started_at,
status_code,
message.as_str(),
),
Err(error) => return Err(error),
};
Ok(Some(result))
})
.await
}
pub(crate) async fn maybe_execute_chatgpt_web_image_stream(
state: &AppState,
plan: &ExecutionPlan,
report_context: Option<&Value>,
) -> Result<Option<ChatGptWebImageStream>, ExecutionRuntimeTransportError> {
if !is_chatgpt_web_image_plan(plan, report_context) {
return Ok(None);
}
let started_at = Instant::now();
let result = match execute_chatgpt_web_image(state, plan, report_context, started_at).await {
Ok(result) => result,
Err(ExecutionRuntimeTransportError::UpstreamHttpStatus {
status_code,
message,
}) => {
chatgpt_web_http_error_execution_result(plan, started_at, status_code, message.as_str())
}
Err(error) => return Err(error),
};
Ok(Some(ChatGptWebImageStream {
frame_stream: execution_result_frame_stream(plan, &result, report_context)?,
report_context: report_context.cloned(),
}))
}
fn is_chatgpt_web_image_plan(plan: &ExecutionPlan, report_context: Option<&Value>) -> bool {
if !plan
.provider_api_format
.eq_ignore_ascii_case("openai:image")
{
return false;
}
let header_marker = plan.headers.iter().any(|(name, value)| {
name.eq_ignore_ascii_case(CHATGPT_WEB_INTERNAL_HEADER) && value == "1"
});
let context_marker = report_context
.and_then(|value| value.get("chatgpt_web_image"))
.and_then(Value::as_bool)
.unwrap_or(false);
header_marker || context_marker
}
async fn execute_chatgpt_web_image(
state: &AppState,
plan: &ExecutionPlan,
report_context: Option<&Value>,
started_at: Instant,
) -> Result<ExecutionResult, ExecutionRuntimeTransportError> {
let body = plan.body.json_body.as_ref().ok_or_else(|| {
ExecutionRuntimeTransportError::UpstreamRequest(
"ChatGPT-Web image plan missing internal request body".to_string(),
)
})?;
if let Some(error) = body.get("error") {
return Ok(json_execution_result(
plan,
400,
json!({ "error": error }),
started_at,
));
}
let request = ChatGptWebImageRequest::from_body(body)?;
let base_url = chatgpt_web_base_url_from_plan(plan);
let token = bearer_token_from_headers(&plan.headers).unwrap_or_default();
let fp = WebFingerprint::new();
debug!(
event_name = "chatgpt_web_image_start",
log_type = "debug",
request_id = %plan.request_id,
candidate_id = ?plan.candidate_id,
upstream_origin = %crate::handlers::shared::security_log_url_origin(&base_url),
operation = %request.operation,
image_count = request.images.len(),
size = %request.size,
ratio = %request.ratio,
"gateway executing ChatGPT-Web image request"
);
web_bootstrap(plan, &base_url, &fp).await?;
let requirements = web_requirements(plan, &base_url, &fp, token.as_str()).await?;
let mut uploads = Vec::new();
for (index, image) in request.images.iter().enumerate() {
uploads.push(
web_upload_image(
state,
plan,
&base_url,
&fp,
token.as_str(),
image,
format!("image_{}.png", index + 1),
)
.await?,
);
}
let conduit = web_prepare_conversation(
plan,
&base_url,
&fp,
token.as_str(),
&requirements,
request.web_model.as_str(),
)
.await?;
let mut summary = web_start_conversation(
plan,
&base_url,
&fp,
token.as_str(),
&requirements,
conduit.as_str(),
&request,
&uploads,
)
.await?;
apply_chatgpt_web_image_quota_request_delta_after_conversation_start(state, plan).await;
spawn_chatgpt_web_image_quota_refresh_after_request(state, plan, &base_url, token.as_str());
filter_uploaded_asset_ids(&mut summary, &uploads);
let mut downloaded = resolve_and_download_images(
state,
plan,
&base_url,
&fp,
token.as_str(),
&mut summary,
&uploads,
)
.await?;
if downloaded.is_empty() && summary.failure.is_none() {
for _ in 0..24 {
if let Some(conversation_id) = summary.conversation_id.as_deref() {
let mut poll = web_poll_conversation(
plan,
&base_url,
&fp,
token.as_str(),
conversation_id,
&uploads,
)
.await?;
merge_web_summary(&mut summary, &mut poll);
filter_uploaded_asset_ids(&mut summary, &uploads);
downloaded = resolve_and_download_images(
state,
plan,
&base_url,
&fp,
token.as_str(),
&mut summary,
&uploads,
)
.await?;
if !downloaded.is_empty() || summary.failure.is_some() {
break;
}
}
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
}
}
let body = if let Some(failure) = summary.failure.as_ref().filter(|_| downloaded.is_empty()) {
build_failed_sse(&request, failure)
} else if let Some(image) = downloaded.into_iter().next() {
build_success_sse(&request, &image, report_context)
} else {
build_failed_sse(
&request,
&json!({
"type": "response.failed",
"response": {
"status": "failed",
"error": {
"code": "chatgpt_web_no_image",
"message": summary.last_text.unwrap_or_else(|| "ChatGPT-Web image proxy returned no image".to_string())
}
}
}),
)
};
bytes_execution_result(
plan,
200,
BTreeMap::from([
("cache-control".to_string(), "no-cache".to_string()),
("content-type".to_string(), "text/event-stream".to_string()),
]),
body.into_bytes(),
started_at,
)
}
#[derive(Debug, Clone)]
struct ChatGptWebImageRequest {
operation: String,
model: String,
web_model: String,
prompt: String,
size: String,
ratio: String,
output_format: String,
quality: Option<String>,
partial_images: u64,
images: Vec<String>,
}
impl ChatGptWebImageRequest {
fn from_body(body: &Value) -> Result<Self, ExecutionRuntimeTransportError> {
let model =
bounded_chatgpt_web_text_field(body, "model", CHATGPT_WEB_IMAGE_MAX_MODEL_BYTES)?
.unwrap_or_else(|| "gpt-image-2".to_string());
let web_model =
bounded_chatgpt_web_text_field(body, "web_model", CHATGPT_WEB_IMAGE_MAX_MODEL_BYTES)?
.unwrap_or_else(|| "gpt-5-5-thinking".to_string());
let prompt =
bounded_chatgpt_web_text_field(body, "prompt", CHATGPT_WEB_IMAGE_MAX_PROMPT_BYTES)?
.unwrap_or_else(|| "Generate a high quality image.".to_string());
let size =
bounded_chatgpt_web_text_field(body, "size", CHATGPT_WEB_IMAGE_MAX_OPTION_BYTES)?
.unwrap_or_else(|| "1024x1024".to_string());
let ratio =
bounded_chatgpt_web_text_field(body, "ratio", CHATGPT_WEB_IMAGE_MAX_OPTION_BYTES)?
.unwrap_or_else(|| "1:1".to_string());
let output_format = bounded_chatgpt_web_text_field(
body,
"output_format",
CHATGPT_WEB_IMAGE_MAX_OPTION_BYTES,
)?
.unwrap_or_else(|| "png".to_string());
let quality =
bounded_chatgpt_web_text_field(body, "quality", CHATGPT_WEB_IMAGE_MAX_OPTION_BYTES)?;
let mut images = Vec::new();
if let Some(values) = body.get("images").and_then(Value::as_array) {
if values.len() > CHATGPT_WEB_IMAGE_MAX_INPUT_IMAGES {
return Err(chatgpt_web_image_request_field_too_large("images"));
}
for value in values {
let Some(value) = value.as_str() else {
continue;
};
let value = value.trim();
if value.is_empty() {
continue;
}
let max_bytes = if value
.get(..5)
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("data:"))
{
maximum_base64_len_for_decoded_limit(
chatgpt_web_image_raw_payload_limit_bytes(),
)
.saturating_add(128)
} else {
CHATGPT_WEB_IMAGE_MAX_EXTERNAL_URL_BYTES
};
if value.len() > max_bytes {
return Err(chatgpt_web_image_request_field_too_large("image reference"));
}
images.push(value.to_string());
}
}
let partial_images = json_u64(body.get("partial_images")).unwrap_or(0);
if partial_images > 3 {
return Err(ExecutionRuntimeTransportError::UpstreamRequest(
"ChatGPT-Web image partial_images must be between 0 and 3".to_string(),
));
}
Ok(Self {
operation: chatgpt_web_image_operation(body.get("operation")),
model,
web_model,
prompt,
size,
ratio,
output_format,
quality,
partial_images,
images,
})
}
}
fn bounded_chatgpt_web_text_field(
body: &Value,
key: &str,
max_bytes: usize,
) -> Result<Option<String>, ExecutionRuntimeTransportError> {
let Some(value) = body.get(key).and_then(Value::as_str) else {
return Ok(None);
};
let value = value.trim();
if value.is_empty() {
return Ok(None);
}
if value.len() > max_bytes {
return Err(chatgpt_web_image_request_field_too_large(key));
}
Ok(Some(value.to_string()))
}
fn chatgpt_web_image_request_field_too_large(field: &str) -> ExecutionRuntimeTransportError {
ExecutionRuntimeTransportError::UpstreamRequest(format!(
"ChatGPT-Web image request {field} exceeds the supported size"
))
}
impl WebFingerprint {
fn new() -> Self {
Self {
user_agent: CHATGPT_WEB_USER_AGENT,
device_id: Uuid::new_v4().to_string(),
session_id: Uuid::new_v4().to_string(),
}
}
}
async fn web_bootstrap(
plan: &ExecutionPlan,
base_url: &str,
fp: &WebFingerprint,
) -> Result<(), ExecutionRuntimeTransportError> {
let headers = {
let mut headers = web_base_headers(fp, "", "");
headers.insert(
"accept".to_string(),
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8"
.to_string(),
);
headers
};
let result =
execute_subrequest(plan, "GET", format!("{base_url}/"), headers, None, false).await?;
ensure_success(&result, "ChatGPT-Web bootstrap")
}
async fn web_requirements(
plan: &ExecutionPlan,
base_url: &str,
fp: &WebFingerprint,
token: &str,
) -> Result<WebRequirement, ExecutionRuntimeTransportError> {
let path = "/backend-api/sentinel/chat-requirements";
let mut headers = web_base_headers(fp, token, path);
headers.insert("content-type".to_string(), "application/json".to_string());
let body = json!({ "p": build_legacy_requirements_token(fp.user_agent) });
let result = execute_subrequest(
plan,
"POST",
format!("{base_url}{path}"),
headers,
Some(RequestBody::from_json(body)),
false,
)
.await?;
ensure_success(&result, "ChatGPT-Web requirements")?;
let payload = execution_result_json(&result)?;
if payload
.get("arkose")
.and_then(|value| value.get("required"))
.and_then(Value::as_bool)
.unwrap_or(false)
{
return Err(ExecutionRuntimeTransportError::UpstreamRequest(
"ChatGPT-Web image proxy requires Arkose".to_string(),
));
}
let token = payload
.get("token")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
ExecutionRuntimeTransportError::UpstreamRequest(
"ChatGPT-Web requirements response missing token".to_string(),
)
})?;
let proof_token = payload
.get("proofofwork")
.filter(|value| {
value
.get("required")
.and_then(Value::as_bool)
.unwrap_or(false)
})
.and_then(|value| {
let seed = value.get("seed").and_then(Value::as_str)?;
let difficulty = value.get("difficulty").and_then(Value::as_str)?;
Some(build_proof_token(seed, difficulty, fp.user_agent))
});
Ok(WebRequirement {
token: token.to_string(),
proof_token,
so_token: payload
.get("so_token")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
})
}
async fn web_prepare_conversation(
plan: &ExecutionPlan,
base_url: &str,
fp: &WebFingerprint,
token: &str,
requirements: &WebRequirement,
model_slug: &str,
) -> Result<String, ExecutionRuntimeTransportError> {
let path = "/backend-api/f/conversation/prepare";
let headers = web_image_headers(fp, token, path, requirements, None, "*/*");
let body = json!({
"action": "next",
"fork_from_shared_post": false,
"parent_message_id": "client-created-root",
"model": model_slug,
"client_prepare_state": "none",
"timezone_offset_min": -480,
"timezone": "Asia/Shanghai",
"conversation_mode": {"kind": "primary_assistant"},
"system_hints": ["picture_v2"],
"attachment_mime_types": ["image/png"],
"supports_buffering": true,
"supported_encodings": ["v1"],
"client_contextual_info": {"app_name": "chatgpt.com"},
"thinking_effort": "standard"
});
let result = execute_subrequest(
plan,
"POST",
format!("{base_url}{path}"),
headers,
Some(RequestBody::from_json(body)),
false,
)
.await?;
ensure_success(&result, "ChatGPT-Web conversation prepare")?;
execution_result_json(&result)?
.get("conduit_token")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.ok_or_else(|| {
ExecutionRuntimeTransportError::UpstreamRequest(
"ChatGPT-Web prepare response missing conduit token".to_string(),
)
})
}
async fn web_start_conversation(
plan: &ExecutionPlan,
base_url: &str,
fp: &WebFingerprint,
token: &str,
requirements: &WebRequirement,
conduit: &str,
request: &ChatGptWebImageRequest,
uploads: &[WebUploadMeta],
) -> Result<WebImageSseSummary, ExecutionRuntimeTransportError> {
let path = "/backend-api/f/conversation";
let headers = web_image_headers(
fp,
token,
path,
requirements,
Some(conduit),
"text/event-stream",
);
let (content, metadata) = web_image_message_content(request.prompt.as_str(), uploads);
let body = json!({
"action": "next",
"fork_from_shared_post": false,
"parent_message_id": "client-created-root",
"model": request.web_model,
"client_prepare_state": "success",
"timezone_offset_min": -480,
"timezone": "Asia/Shanghai",
"conversation_mode": {"kind": "primary_assistant"},
"enable_message_followups": true,
"system_hints": [],
"supports_buffering": true,
"supported_encodings": ["v1"],
"client_contextual_info": {
"is_dark_mode": false,
"time_since_loaded": 51,
"page_height": 1111,
"page_width": 1731,
"pixel_ratio": 1.5,
"screen_height": 1440,
"screen_width": 2560,
"app_name": "chatgpt.com"
},
"paragen_cot_summary_display_override": "allow",
"force_parallel_switch": "auto",
"thinking_effort": "standard",
"messages": [{
"id": Uuid::new_v4().to_string(),
"author": {"role": "user"},
"create_time": current_unix_secs(),
"content": content,
"metadata": metadata
}]
});
let result = execute_subrequest(
plan,
"POST",
format!("{base_url}{path}"),
headers,
Some(RequestBody::from_json(body)),
true,
)
.await?;
ensure_success(&result, "ChatGPT-Web conversation")?;
Ok(parse_web_image_sse(&execution_result_bytes(&result)?))
}
async fn web_poll_conversation(
plan: &ExecutionPlan,
base_url: &str,
fp: &WebFingerprint,
token: &str,
conversation_id: &str,
uploads: &[WebUploadMeta],
) -> Result<WebImageSseSummary, ExecutionRuntimeTransportError> {
let conversation_id = validated_web_opaque_id(conversation_id, "conversation ID")?;
let path = format!("/backend-api/conversation/{conversation_id}");
let mut headers = web_base_headers(fp, token, path.as_str());
headers.insert("accept".to_string(), "application/json".to_string());
let result = execute_subrequest(
plan,
"GET",
format!("{base_url}{path}"),
headers,
None,
false,
)
.await?;
ensure_success(&result, "ChatGPT-Web conversation poll")?;
let mut summary = WebImageSseSummary::default();
extract_web_image_values(&execution_result_json(&result)?, &mut summary);
filter_uploaded_asset_ids(&mut summary, uploads);
Ok(summary)
}
async fn resolve_and_download_images(
state: &AppState,
plan: &ExecutionPlan,
base_url: &str,
fp: &WebFingerprint,
token: &str,
summary: &mut WebImageSseSummary,
uploads: &[WebUploadMeta],
) -> Result<Vec<DownloadedImage>, ExecutionRuntimeTransportError> {
let mut urls = Vec::new();
add_unique_values(&mut urls, summary.direct_urls.iter().cloned());
let resolved = web_resolve_image_urls(plan, base_url, fp, token, summary, uploads).await?;
add_unique_values(&mut urls, resolved);
let mut downloaded = Vec::new();
for url in urls {
match web_download_image(
state,
plan,
base_url,
fp,
token,
url.as_str(),
WebImageDownloadTrust::ProviderOutput,
)
.await
{
Ok(image) => {
downloaded.push(image);
break;
}
Err(err) => {
debug!(
event_name = "chatgpt_web_image_download_failed",
log_type = "debug",
request_id = %plan.request_id,
candidate_id = ?plan.candidate_id,
error = %safe_transport_error_message(&err),
"gateway failed to download one ChatGPT-Web image URL"
);
}
}
}
Ok(downloaded)
}
async fn web_resolve_image_urls(
plan: &ExecutionPlan,
base_url: &str,
fp: &WebFingerprint,
token: &str,
summary: &WebImageSseSummary,
uploads: &[WebUploadMeta],
) -> Result<Vec<String>, ExecutionRuntimeTransportError> {
let mut urls = Vec::new();
let uploaded_ids = uploaded_file_ids(uploads);
let conversation_id = summary
.conversation_id
.as_deref()
.map(|value| validated_web_opaque_id(value, "conversation ID"))
.transpose()?;
for raw_file_id in &summary.file_ids {
let file_id = validated_web_file_id(raw_file_id)?;
if uploaded_ids.contains(file_id) || file_id == "file_upload" {
continue;
}
let mut path = format!("/backend-api/files/download/{file_id}");
if let Some(conversation_id) = conversation_id {
path.push_str("?conversation_id=");
path.push_str(conversation_id);
path.push_str("&inline=false");
}
if let Some(url) = web_download_url(plan, base_url, fp, token, path.as_str()).await? {
add_unique_values(&mut urls, [url]);
}
}
if let Some(conversation_id) = conversation_id {
for raw_sediment_id in &summary.sediment_ids {
let sediment_id = validated_web_opaque_id(raw_sediment_id, "sediment ID")?;
if uploaded_ids.contains(sediment_id) {
continue;
}
let path = format!(
"/backend-api/conversation/{conversation_id}/attachment/{sediment_id}/download"
);
if let Some(url) = web_download_url(plan, base_url, fp, token, path.as_str()).await? {
add_unique_values(&mut urls, [url]);
}
}
}
Ok(urls)
}
async fn web_download_url(
plan: &ExecutionPlan,
base_url: &str,
fp: &WebFingerprint,
token: &str,
path: &str,
) -> Result<Option<String>, ExecutionRuntimeTransportError> {
let mut headers = web_base_headers(fp, token, path);
headers.insert("accept".to_string(), "application/json".to_string());
let result = execute_subrequest(
plan,
"GET",
format!("{base_url}{path}"),
headers,
None,
false,
)
.await?;
if !(200..300).contains(&result.status_code) {
return Ok(None);
}
let body = execution_result_json(&result)?;
Ok(body
.get("download_url")
.or_else(|| body.get("url"))
.and_then(Value::as_str)
.map(str::trim)
.and_then(|value| {
if value.is_empty() || value.len() > CHATGPT_WEB_IMAGE_MAX_EXTERNAL_URL_BYTES {
return None;
}
let base = url::Url::parse(base_url).ok()?;
let absolute = url::Url::parse(value).is_ok();
let url = if absolute {
url::Url::parse(value).ok()?
} else {
base.join(value).ok()?
};
validate_web_image_http_url(&url).ok()?;
// Relative download paths are expected from the authenticated
// ChatGPT API. Do not let a provider response turn one into an
// arbitrary cross-origin target through URL joining.
if !absolute && !web_download_url_is_same_origin(&base, &url) {
return None;
}
let serialized = url.to_string();
(serialized.len() <= CHATGPT_WEB_IMAGE_MAX_EXTERNAL_URL_BYTES).then_some(serialized)
}))
}
async fn web_download_image(
_state: &AppState,
plan: &ExecutionPlan,
base_url: &str,
fp: &WebFingerprint,
token: &str,
raw_url: &str,
trust: WebImageDownloadTrust,
) -> Result<DownloadedImage, ExecutionRuntimeTransportError> {
if let Some(data) = parse_data_url(raw_url) {
return Ok(data);
}
let payload = match trust {
WebImageDownloadTrust::UntrustedInput => {
let url = parse_absolute_web_image_url(raw_url)?;
download_public_web_image(url, plan.timeouts.as_ref(), false).await?
}
WebImageDownloadTrust::ProviderOutput => {
download_provider_web_image(plan, base_url, fp, token, raw_url).await?
}
};
let data = payload.data;
let mime = validate_web_image_payload(&data, payload.content_type.as_deref())?.to_string();
let (width, height) = image_dimensions(&data);
Ok(DownloadedImage {
b64_json: base64::engine::general_purpose::STANDARD.encode(data),
mime,
width,
height,
})
}
fn parse_absolute_web_image_url(raw_url: &str) -> Result<url::Url, ExecutionRuntimeTransportError> {
let url = url::Url::parse(raw_url.trim()).map_err(|err| {
ExecutionRuntimeTransportError::UpstreamRequest(format!(
"ChatGPT-Web image URL is invalid: {err}"
))
})?;
validate_web_image_http_url(&url)?;
Ok(url)
}
fn validate_web_image_http_url(url: &url::Url) -> Result<(), ExecutionRuntimeTransportError> {
if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
return Err(ExecutionRuntimeTransportError::UpstreamRequest(
"ChatGPT-Web image URL must be an absolute http or https URL".to_string(),
));
}
if !url.username().is_empty() || url.password().is_some() {
return Err(ExecutionRuntimeTransportError::UpstreamRequest(
"ChatGPT-Web image URL must not contain credentials".to_string(),
));
}
Ok(())
}
/// Return the canonical MIME type for a supported, non-active image payload.
///
/// Content-Type is metadata supplied by an untrusted upstream and must not be
/// used as the sole type check: an HTML/SVG response can be labelled as
/// `image/png`. Require a real PNG/JPEG/WebP signature and, when a concrete
/// content type is supplied, require it to agree with the signature.
fn validate_web_image_payload(
data: &[u8],
content_type: Option<&str>,
) -> Result<&'static str, ExecutionRuntimeTransportError> {
if data.is_empty() {
return Err(ExecutionRuntimeTransportError::UpstreamRequest(
"ChatGPT-Web image download returned empty body".to_string(),
));
}
let detected = detected_web_image_mime(data).ok_or_else(|| {
ExecutionRuntimeTransportError::UpstreamRequest(
"ChatGPT-Web image download returned an unsupported image payload".to_string(),
)
})?;
let declared = declared_web_image_mime(content_type)?;
if let Some(declared) = declared {
if declared != detected {
return Err(ExecutionRuntimeTransportError::UpstreamRequest(
"ChatGPT-Web image content type does not match its payload".to_string(),
));
}
}
Ok(detected)
}
/// Parse a response Content-Type into one of the formats we can safely pass
/// through the OpenAI image response surface. Generic octet-stream is
/// allowed only because the payload signature is checked independently.
fn declared_web_image_mime(
content_type: Option<&str>,
) -> Result<Option<&'static str>, ExecutionRuntimeTransportError> {
let Some(content_type) = content_type else {
return Ok(None);
};
let token = content_type
.split(';')
.next()
.map(str::trim)
.unwrap_or_default();
if token.is_empty()
|| token
.bytes()
.any(|byte| byte.is_ascii_whitespace() || byte.is_ascii_control() || byte == b',')
{
return Err(ExecutionRuntimeTransportError::UpstreamRequest(
"ChatGPT-Web image response has an invalid content type".to_string(),
));
}
if token.eq_ignore_ascii_case("application/octet-stream")
|| token.eq_ignore_ascii_case("binary/octet-stream")
{
return Ok(None);
}
let mime =
if token.eq_ignore_ascii_case("image/png") || token.eq_ignore_ascii_case("image/x-png") {
Some("image/png")
} else if token.eq_ignore_ascii_case("image/jpeg")
|| token.eq_ignore_ascii_case("image/jpg")
|| token.eq_ignore_ascii_case("image/pjpeg")
{
Some("image/jpeg")
} else if token.eq_ignore_ascii_case("image/webp") {
Some("image/webp")
} else {
// This intentionally rejects image/svg+xml, image/avif, generic
// image/*, text/html, and all other active/unsupported types.
None
};
mime.ok_or_else(|| {
ExecutionRuntimeTransportError::UpstreamRequest(
"ChatGPT-Web image response has an unsupported content type".to_string(),
)
})
.map(Some)
}
fn detected_web_image_mime(data: &[u8]) -> Option<&'static str> {
// Require the PNG signature and an IHDR chunk before treating the body as
// PNG. This also gives image_dimensions a safe minimum length.
if data.len() >= 24 && data.starts_with(b"\x89PNG\r\n\x1a\n") && &data[12..16] == b"IHDR" {
return Some("image/png");
}
// JPEG's SOI marker must be followed by a marker prefix. This rejects a
// bare/truncated `ff d8` body while leaving full structural validation to
// the image decoder downstream.
if data.len() >= 3 && data.starts_with(&[0xff, 0xd8, 0xff]) {
return Some("image/jpeg");
}
// WebP is a RIFF container with a WEBP form type.
if data.len() >= 12 && &data[..4] == b"RIFF" && &data[8..12] == b"WEBP" {
return Some("image/webp");
}
None
}
async fn download_provider_web_image(
plan: &ExecutionPlan,
base_url: &str,
fp: &WebFingerprint,
token: &str,
raw_url: &str,
) -> Result<WebImageHttpPayload, ExecutionRuntimeTransportError> {
let base = parse_absolute_web_image_url(base_url)?;
let mut current = base.join(raw_url.trim()).map_err(|err| {
ExecutionRuntimeTransportError::UpstreamRequest(format!(
"ChatGPT-Web image URL is invalid: {err}"
))
})?;
validate_web_image_http_url(&current)?;
let mut redirects = 0usize;
loop {
if !web_download_url_is_same_origin(&base, &current) {
// Provider-generated storage URLs may be resolved to RFC 2544
// synthetic addresses by a local DNS interception tool. The
// public downloader still decides whether the exact storage
// origin is eligible; this flag is never enabled for untrusted
// request input.
return download_public_web_image(current, plan.timeouts.as_ref(), true).await;
}
let path = match current.query() {
Some(query) => format!("{}?{query}", current.path()),
None => current.path().to_string(),
};
let mut headers = BTreeMap::new();
if is_authenticated_web_download_url(&base, &current) {
headers.extend(web_base_headers(fp, token, path.as_str()));
}
headers.insert(
"accept".to_string(),
"image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8".to_string(),
);
let result = execute_subrequest(
plan,
"GET",
current.as_str().to_string(),
headers,
None,
false,
)
.await?;
if (300..400).contains(&result.status_code) {
if redirects >= CHATGPT_WEB_IMAGE_DOWNLOAD_MAX_REDIRECTS {
return Err(ExecutionRuntimeTransportError::UpstreamRequest(
"ChatGPT-Web image download exceeded redirect limit".to_string(),
));
}
let location = result.headers.get("location").ok_or_else(|| {
ExecutionRuntimeTransportError::UpstreamRequest(
"ChatGPT-Web image redirect is missing Location header".to_string(),
)
})?;
current = current.join(location).map_err(|err| {
ExecutionRuntimeTransportError::UpstreamRequest(format!(
"ChatGPT-Web image redirect URL is invalid: {err}"
))
})?;
validate_web_image_http_url(&current)?;
redirects += 1;
continue;
}
ensure_success(&result, "ChatGPT-Web image download")?;
return Ok(WebImageHttpPayload {
data: execution_result_bytes_with_limit(
&result,
chatgpt_web_image_raw_payload_limit_bytes(),
)?,
content_type: result.headers.get("content-type").cloned(),
});
}
}
async fn download_public_web_image(
mut current: url::Url,
timeouts: Option<&ExecutionTimeouts>,
allow_benchmarking_fake_ip: bool,
) -> Result<WebImageHttpPayload, ExecutionRuntimeTransportError> {
let mut redirects = 0usize;
let total_timeout = bounded_chatgpt_web_image_timeout(
timeouts.and_then(|value| value.total_ms),
CHATGPT_WEB_IMAGE_PUBLIC_TOTAL_TIMEOUT_MS,
);
let deadline = Instant::now() + total_timeout;
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Err(ExecutionRuntimeTransportError::UpstreamRequest(
"ChatGPT-Web public image download timed out".to_string(),
));
}
validate_web_image_http_url(&current)?;
let connect_timeout = bounded_chatgpt_web_image_timeout(
timeouts.and_then(|value| value.connect_ms),
CHATGPT_WEB_IMAGE_PUBLIC_CONNECT_TIMEOUT_MS,
);
let (host, resolved) = resolve_public_web_image_addrs(
&current,
connect_timeout.min(remaining),
allow_benchmarking_fake_ip,
)
.await?;
let mut builder = reqwest::Client::builder()
.no_proxy()
.redirect(reqwest::redirect::Policy::none());
builder = builder
.connect_timeout(
bounded_chatgpt_web_image_timeout(
timeouts.and_then(|value| value.connect_ms),
CHATGPT_WEB_IMAGE_PUBLIC_CONNECT_TIMEOUT_MS,
)
.min(remaining),
)
.read_timeout(
bounded_chatgpt_web_image_timeout(
timeouts.and_then(|value| value.read_ms),
CHATGPT_WEB_IMAGE_PUBLIC_READ_TIMEOUT_MS,
)
.min(remaining),
)
.timeout(remaining);
if host.parse::<IpAddr>().is_err() {
builder = builder.resolve_to_addrs(host.as_str(), &resolved);
}
let client = builder
.build()
.map_err(ExecutionRuntimeTransportError::ClientBuild)?;
let response = client
.get(current.clone())
.header(
reqwest::header::ACCEPT,
"image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
)
.send()
.await
.map_err(|err| {
ExecutionRuntimeTransportError::UpstreamRequest(format!(
"ChatGPT-Web public image download failed: {}",
format_upstream_request_error(&err)
))
})?;
if response.status().is_redirection() {
if redirects >= CHATGPT_WEB_IMAGE_DOWNLOAD_MAX_REDIRECTS {
return Err(ExecutionRuntimeTransportError::UpstreamRequest(
"ChatGPT-Web image download exceeded redirect limit".to_string(),
));
}
let location = response
.headers()
.get(reqwest::header::LOCATION)
.and_then(|value| value.to_str().ok())
.ok_or_else(|| {
ExecutionRuntimeTransportError::UpstreamRequest(
"ChatGPT-Web image redirect is missing Location header".to_string(),
)
})?;
current = current.join(location).map_err(|err| {
ExecutionRuntimeTransportError::UpstreamRequest(format!(
"ChatGPT-Web image redirect URL is invalid: {err}"
))
})?;
redirects += 1;
continue;
}
if !response.status().is_success() {
return Err(ExecutionRuntimeTransportError::UpstreamRequest(format!(
"ChatGPT-Web image download returned {}",
response.status().as_u16()
)));
}
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(ToOwned::to_owned);
let data = aether_http::read_response_bytes_with_limit(
response,
chatgpt_web_image_raw_payload_limit_bytes(),
)
.await
.map_err(|err| {
ExecutionRuntimeTransportError::UpstreamRequest(format!(
"ChatGPT-Web public image body read failed: {err}"
))
})?;
return Ok(WebImageHttpPayload { data, content_type });
}
}
fn bounded_chatgpt_web_image_timeout(configured_ms: Option<u64>, default_ms: u64) -> Duration {
Duration::from_millis(configured_ms.unwrap_or(default_ms).clamp(1, 1_200_000))
}
async fn resolve_public_web_image_addrs(
url: &url::Url,
lookup_timeout: Duration,
allow_benchmarking_fake_ip: bool,
) -> Result<(String, Vec<SocketAddr>), ExecutionRuntimeTransportError> {
let host = url.host_str().ok_or_else(|| {
ExecutionRuntimeTransportError::UpstreamRequest(
"ChatGPT-Web image URL is missing a host".to_string(),
)
})?;
let port = url.port_or_known_default().ok_or_else(|| {
ExecutionRuntimeTransportError::UpstreamRequest(
"ChatGPT-Web image URL is missing a port".to_string(),
)
})?;
let resolved = aether_http::lookup_host_with_limits(host, port, lookup_timeout)
.await
.map_err(|error| {
let message = match error.kind() {
std::io::ErrorKind::TimedOut => "ChatGPT-Web image URL DNS resolution timed out",
std::io::ErrorKind::InvalidData => {
"ChatGPT-Web image URL DNS resolution returned too many addresses"
}
_ => "ChatGPT-Web image URL DNS resolution failed",
};
ExecutionRuntimeTransportError::UpstreamRequest(message.to_string())
})?;
if resolved.is_empty() {
return Err(ExecutionRuntimeTransportError::UpstreamRequest(
"ChatGPT-Web image URL DNS resolution returned no addresses".to_string(),
));
}
validate_public_web_image_addresses(url, &resolved, allow_benchmarking_fake_ip)?;
Ok((host.to_string(), resolved))
}
fn validate_public_web_image_addresses(
url: &url::Url,
addresses: &[SocketAddr],
allow_benchmarking_fake_ip: bool,
) -> Result<(), ExecutionRuntimeTransportError> {
let allows_benchmarking_fake_ip =
allow_benchmarking_fake_ip && web_image_storage_origin_allows_benchmarking_fake_ip(url);
if addresses.iter().any(|address| {
aether_http::is_private_or_reserved_ip(address.ip())
&& !(allows_benchmarking_fake_ip
&& aether_http::is_ipv4_benchmarking_fake_ip(address.ip()))
}) {
return Err(ExecutionRuntimeTransportError::UpstreamRequest(
"ChatGPT-Web image URL resolves to a private or reserved address".to_string(),
));
}
Ok(())
}
/// Synthetic DNS is accepted only for the storage origins that ChatGPT uses
/// for generated assets and upload blobs. In particular, a user-supplied URL
/// on an arbitrary host cannot opt into this exception merely by resolving to
/// the RFC 2544 benchmark range.
fn web_image_storage_origin_allows_benchmarking_fake_ip(url: &url::Url) -> bool {
url.scheme().eq_ignore_ascii_case("https")
&& url.port_or_known_default() == Some(443)
&& url.username().is_empty()
&& url.password().is_none()
&& url
.host_str()
.is_some_and(chatgpt_web_upload_host_is_allowed)
}
/// Validate the destination returned by ChatGPT's upload-metadata endpoint.
///
/// The upload URL is provider-controlled data, not a trusted request target.
/// Keep this boundary narrower than the generic execution URL policy: uploads
/// must go to the storage origins used by ChatGPT, over HTTPS, without
/// credentials or fragments. Azure SAS query parameters are intentionally
/// retained because they carry the upload authorization.
fn validate_chatgpt_web_upload_url(
raw_url: &str,
) -> Result<url::Url, ExecutionRuntimeTransportError> {
let raw_url = raw_url.trim();
if raw_url.is_empty() || raw_url.len() > CHATGPT_WEB_IMAGE_MAX_UPLOAD_URL_BYTES {
return Err(ExecutionRuntimeTransportError::UpstreamRequest(
"ChatGPT-Web upload URL is invalid or too large".to_string(),
));
}
let url = url::Url::parse(raw_url).map_err(|_| {
ExecutionRuntimeTransportError::UpstreamRequest(
"ChatGPT-Web upload URL is invalid".to_string(),
)
})?;
if url.scheme() != "https"
|| url.host_str().is_none()
|| url.port().is_some_and(|port| port != 443)
{
return Err(ExecutionRuntimeTransportError::UpstreamRequest(
"ChatGPT-Web upload URL must use HTTPS on the default port".to_string(),
));
}
if !url.username().is_empty() || url.password().is_some() || url.fragment().is_some() {
return Err(ExecutionRuntimeTransportError::UpstreamRequest(
"ChatGPT-Web upload URL must not contain credentials or a fragment".to_string(),
));
}
let host = url.host_str().unwrap_or_default();
if host.parse::<IpAddr>().is_ok() || !chatgpt_web_upload_host_is_allowed(host) {
return Err(ExecutionRuntimeTransportError::UpstreamRequest(
"ChatGPT-Web upload URL host is not an allowed storage origin".to_string(),
));
}
if url.path().len() > CHATGPT_WEB_IMAGE_MAX_UPLOAD_URL_BYTES
|| url
.query()
.is_some_and(|query| query.len() > CHATGPT_WEB_IMAGE_MAX_UPLOAD_URL_BYTES)
{
return Err(ExecutionRuntimeTransportError::UpstreamRequest(
"ChatGPT-Web upload URL is too large".to_string(),
));
}
Ok(url)
}
fn chatgpt_web_upload_host_is_allowed(host: &str) -> bool {
if !web_dns_host_is_valid(host) {
return false;
}
if web_host_is_domain_or_subdomain(host, "files.oaiusercontent.com") {
return true;
}
if web_host_is_domain_or_subdomain(host, "oaidalleapiprodscus.blob.core.windows.net") {
return true;
}
web_host_is_strict_subdomain(host, "blob.core.windows.net")
&& !web_host_is_domain_or_subdomain(host, "openaiassets.blob.core.windows.net")
}
/// PUT image bytes to a validated ChatGPT storage URL using a DNS-pinned,
/// proxy-free client. The generic execution runtime intentionally supports
/// configured proxies and broad public HTTPS targets; that is inappropriate
/// for a provider-supplied upload destination.
async fn upload_chatgpt_web_blob(
plan: &ExecutionPlan,
upload_url: &url::Url,
content_type: &str,
user_agent: &str,
base_url: &str,
body: Vec<u8>,
) -> Result<(), ExecutionRuntimeTransportError> {
let total_timeout = bounded_chatgpt_web_image_timeout(
plan.timeouts
.as_ref()
.and_then(|timeouts| timeouts.total_ms),
CHATGPT_WEB_IMAGE_PUBLIC_TOTAL_TIMEOUT_MS,
);
let connect_timeout = bounded_chatgpt_web_image_timeout(
plan.timeouts
.as_ref()
.and_then(|timeouts| timeouts.connect_ms),
CHATGPT_WEB_IMAGE_PUBLIC_CONNECT_TIMEOUT_MS,
)
.min(total_timeout);
let read_timeout = bounded_chatgpt_web_image_timeout(
plan.timeouts.as_ref().and_then(|timeouts| timeouts.read_ms),
CHATGPT_WEB_IMAGE_PUBLIC_READ_TIMEOUT_MS,
)
.min(total_timeout);
let (host, resolved) =
resolve_public_web_image_addrs(upload_url, connect_timeout, true).await?;
let client = reqwest::Client::builder()
.no_proxy()
.redirect(reqwest::redirect::Policy::none())
.connect_timeout(connect_timeout)
.read_timeout(read_timeout)
.timeout(total_timeout)
.resolve_to_addrs(host.as_str(), &resolved)
.build()
.map_err(|_| {
ExecutionRuntimeTransportError::UpstreamRequest(
"ChatGPT-Web upload client initialization failed".to_string(),
)
})?;
let response = client
.put(upload_url.clone())
.header(reqwest::header::CONTENT_TYPE, content_type)
.header("x-ms-blob-type", "BlockBlob")
.header("x-ms-version", "2020-04-08")
.header(reqwest::header::ORIGIN, base_url)
.header(reqwest::header::REFERER, format!("{base_url}/"))
.header(reqwest::header::USER_AGENT, user_agent)
.body(body)
.send()
.await
.map_err(|_| {
ExecutionRuntimeTransportError::UpstreamRequest(
"ChatGPT-Web upload request failed".to_string(),
)
})?;
let status_code = response.status().as_u16();
if !(200..300).contains(&status_code) {
return Err(ExecutionRuntimeTransportError::UpstreamHttpStatus {
status_code,
message: chatgpt_web_stage_http_error_message("ChatGPT-Web upload blob", status_code),
});
}
aether_http::read_response_bytes_with_limit(
response,
CHATGPT_WEB_IMAGE_UPLOAD_RESPONSE_LIMIT_BYTES,
)
.await
.map_err(|_| {
ExecutionRuntimeTransportError::UpstreamRequest(
"ChatGPT-Web upload response body is invalid or too large".to_string(),
)
})?;
Ok(())
}
async fn web_upload_image(
state: &AppState,
plan: &ExecutionPlan,
base_url: &str,
fp: &WebFingerprint,
token: &str,
ref_url: &str,
file_name: String,
) -> Result<WebUploadMeta, ExecutionRuntimeTransportError> {
let image = web_download_image(
state,
plan,
base_url,
fp,
token,
ref_url,
WebImageDownloadTrust::UntrustedInput,
)
.await?;
let bytes = decode_base64_body_with_limit(
image.b64_json.as_str(),
chatgpt_web_image_raw_payload_limit_bytes(),
)?;
let file_size = bytes.len();
let path = "/backend-api/files";
let mut headers = web_base_headers(fp, token, path);
headers.insert("content-type".to_string(), "application/json".to_string());
headers.insert("accept".to_string(), "application/json".to_string());
let body = json!({
"file_name": file_name,
"file_size": bytes.len(),
"use_case": "multimodal",
"width": image.width.unwrap_or(1024),
"height": image.height.unwrap_or(1024)
});
let result = execute_subrequest(
plan,
"POST",
format!("{base_url}{path}"),
headers,
Some(RequestBody::from_json(body)),
false,
)
.await?;
ensure_success(&result, "ChatGPT-Web upload metadata")?;
let upload_payload = execution_result_json(&result)?;
let raw_file_id = upload_payload
.get("file_id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
ExecutionRuntimeTransportError::UpstreamRequest(
"ChatGPT-Web upload response missing file_id".to_string(),
)
})?;
let file_id = validated_web_file_id(raw_file_id)?.to_string();
let upload_url = upload_payload
.get("upload_url")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
ExecutionRuntimeTransportError::UpstreamRequest(
"ChatGPT-Web upload response missing upload_url".to_string(),
)
})?;
let upload_url = validate_chatgpt_web_upload_url(upload_url)?;
upload_chatgpt_web_blob(
plan,
&upload_url,
image.mime.as_str(),
fp.user_agent,
base_url,
bytes,
)
.await?;
let uploaded_path = format!("/backend-api/files/{file_id}/uploaded");
let mut uploaded_headers = web_base_headers(fp, token, uploaded_path.as_str());
uploaded_headers.insert("content-type".to_string(), "application/json".to_string());
let uploaded_result = execute_subrequest(
plan,
"POST",
format!("{base_url}{uploaded_path}"),
uploaded_headers,
Some(RequestBody::from_json(json!({}))),
false,
)
.await?;
ensure_success(&uploaded_result, "ChatGPT-Web upload confirm")?;
let library_file_id = web_process_upload_stream(
plan,
base_url,
fp,
token,
file_id.as_str(),
file_name.as_str(),
)
.await?;
Ok(WebUploadMeta {
file_id,
library_file_id,
file_name,
file_size,
mime: image.mime,
width: image.width,
height: image.height,
})
}
async fn web_process_upload_stream(
plan: &ExecutionPlan,
base_url: &str,
fp: &WebFingerprint,
token: &str,
file_id: &str,
file_name: &str,
) -> Result<Option<String>, ExecutionRuntimeTransportError> {
let path = "/backend-api/files/process_upload_stream";
let mut headers = web_base_headers(fp, token, path);
headers.insert("content-type".to_string(), "application/json".to_string());
headers.insert("accept".to_string(), "text/event-stream".to_string());
let body = json!({
"file_id": file_id,
"use_case": "multimodal",
"index_for_retrieval": false,
"file_name": file_name,
"library_persistence_mode": "opportunistic",
"metadata": {"store_in_library": true},
"entry_surface": "chat_composer"
});
let result = execute_subrequest(
plan,
"POST",
format!("{base_url}{path}"),
headers,
Some(RequestBody::from_json(body)),
true,
)
.await?;
ensure_success(&result, "ChatGPT-Web process upload")?;
let text = String::from_utf8_lossy(&execution_result_bytes(&result)?).to_string();
Ok(text.lines().find_map(|line| {
serde_json::from_str::<Value>(line.trim())
.ok()
.and_then(|value| {
value
.get("extra")
.and_then(|extra| extra.get("metadata_object_id"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| web_opaque_id_is_safe(value))
.map(ToOwned::to_owned)
})
}))
}
async fn execute_subrequest(
plan: &ExecutionPlan,
method: &str,
url: String,
headers: BTreeMap<String, String>,
body: Option<RequestBody>,
stream: bool,
) -> Result<ExecutionResult, ExecutionRuntimeTransportError> {
let subplan = ExecutionPlan {
request_id: plan.request_id.clone(),
candidate_id: plan.candidate_id.clone(),
provider_name: plan.provider_name.clone(),
provider_id: plan.provider_id.clone(),
endpoint_id: plan.endpoint_id.clone(),
key_id: plan.key_id.clone(),
method: method.to_string(),
url,
headers,
content_type: None,
content_encoding: None,
body: body.unwrap_or(RequestBody {
json_body: None,
body_bytes_b64: None,
body_ref: None,
}),
stream,
client_api_format: plan.client_api_format.clone(),
provider_api_format: plan.provider_api_format.clone(),
model_name: plan.model_name.clone(),
proxy: plan.proxy.clone(),
transport_profile: chatgpt_web_image_transport_profile(plan),
timeouts: plan.timeouts.clone(),
};
DirectSyncExecutionRuntime::new()
.execute_sync(&subplan)
.await
}
async fn apply_chatgpt_web_image_quota_request_delta_after_conversation_start(
state: &AppState,
plan: &ExecutionPlan,
) {
if !state.has_provider_catalog_data_reader() || !state.has_provider_catalog_data_writer() {
return;
}
if plan.key_id.trim().is_empty() || plan.provider_id.trim().is_empty() {
return;
}
match apply_chatgpt_web_image_quota_request_delta(state, plan).await {
Ok(true) => {
debug!(
event_name = "chatgpt_web_image_quota_request_delta_applied",
log_type = "debug",
request_id = %plan.request_id,
candidate_id = ?plan.candidate_id,
provider_id = %plan.provider_id,
key_id = %plan.key_id,
"gateway persisted ChatGPT-Web image quota request delta after conversation start"
);
}
Ok(false) => {
debug!(
event_name = "chatgpt_web_image_quota_request_delta_skipped",
log_type = "debug",
request_id = %plan.request_id,
candidate_id = ?plan.candidate_id,
provider_id = %plan.provider_id,
key_id = %plan.key_id,
"gateway skipped ChatGPT-Web image quota request delta after conversation start"
);
}
Err(err) => {
warn!(
event_name = "chatgpt_web_image_quota_request_delta_failed",
log_type = "ops",
request_id = %plan.request_id,
candidate_id = ?plan.candidate_id,
provider_id = %plan.provider_id,
key_id = %plan.key_id,
error = %err,
"gateway failed to persist ChatGPT-Web image quota request delta after conversation start"
);
}
}
}
fn spawn_chatgpt_web_image_quota_refresh_after_request(
state: &AppState,
plan: &ExecutionPlan,
base_url: &str,
token: &str,
) {
if !state.has_provider_catalog_data_reader() || !state.has_provider_catalog_data_writer() {
return;
}
let token = token.trim();
if token.is_empty() || plan.key_id.trim().is_empty() || plan.provider_id.trim().is_empty() {
return;
}
let state = state.clone();
let plan = plan.clone();
let base_url = base_url.to_string();
let token = token.to_string();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(5)).await;
if let Err(err) =
refresh_chatgpt_web_image_quota_after_success(&state, &plan, &base_url, &token).await
{
warn!(
event_name = "chatgpt_web_image_quota_refresh_after_success_failed",
log_type = "ops",
request_id = %plan.request_id,
candidate_id = ?plan.candidate_id,
provider_id = %plan.provider_id,
key_id = %plan.key_id,
error = %err,
"gateway failed to refresh ChatGPT-Web image quota after a generation request"
);
}
});
}
async fn apply_chatgpt_web_image_quota_request_delta(
state: &AppState,
plan: &ExecutionPlan,
) -> Result<bool, String> {
let key_id = plan.key_id.trim();
let provider_id = plan.provider_id.trim();
if key_id.is_empty() || provider_id.is_empty() {
return Ok(false);
}
let request_dedup_key = chatgpt_web_image_quota_request_delta_dedup_key(plan);
for attempt in 0..RUNTIME_METADATA_CAS_MAX_ATTEMPTS {
let Some(mut latest_key) = state
.read_provider_catalog_keys_by_ids(&[key_id.to_string()])
.await
.map_err(|_| "ChatGPT-Web quota state read failed".to_string())?
.into_iter()
.find(|key| key.id == key_id && key.provider_id == provider_id)
else {
return Ok(false);
};
let expected_namespace_value = latest_key
.upstream_metadata
.as_ref()
.and_then(Value::as_object)
.and_then(|metadata| metadata.get("chatgpt_web"))
.cloned();
let mut metadata = expected_namespace_value
.as_ref()
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
let now_unix_secs = current_unix_secs();
if !apply_chatgpt_web_image_quota_request_delta_to_metadata(
&mut metadata,
latest_key.status_snapshot.as_ref(),
now_unix_secs,
request_dedup_key.as_deref(),
) {
return Ok(false);
}
let namespace_value =
admin_provider_metadata_bucket_safe_json("chatgpt_web", Some(&Value::Object(metadata)));
let updated_upstream_metadata = merge_provider_metadata_object(
latest_key.upstream_metadata.as_ref(),
"chatgpt_web",
namespace_value.clone(),
);
latest_key.upstream_metadata = updated_upstream_metadata;
latest_key.status_snapshot = sync_provider_key_quota_status_snapshot(
latest_key.status_snapshot.as_ref(),
"chatgpt_web",
latest_key.upstream_metadata.as_ref(),
"image_request_local",
);
latest_key.status_snapshot = sync_provider_key_oauth_status_snapshot(
latest_key.status_snapshot.as_ref(),
&latest_key,
);
latest_key.updated_at_unix_secs = Some(now_unix_secs);
let persisted = state
.update_provider_catalog_key_runtime_metadata(
&ProviderCatalogKeyRuntimeMetadataUpdate {
key_id: latest_key.id.clone(),
namespace: "chatgpt_web".to_string(),
expected_upstream_metadata_value: expected_namespace_value,
upstream_metadata_value: namespace_value,
status_snapshot_patch: provider_operational_status_patch(
latest_key.status_snapshot.as_ref(),
),
updated_at_unix_secs: latest_key.updated_at_unix_secs,
},
)
.await
.map_err(|_| "ChatGPT-Web quota state update failed".to_string())?;
if persisted {
return Ok(true);
}
if attempt + 1 < RUNTIME_METADATA_CAS_MAX_ATTEMPTS {
let backoff_us = 50_u64.saturating_mul((attempt + 1) as u64).min(1_000);
tokio::time::sleep(Duration::from_micros(backoff_us)).await;
}
}
Ok(false)
}
fn apply_chatgpt_web_image_quota_request_delta_to_metadata(
metadata: &mut Map<String, Value>,
status_snapshot: Option<&Value>,
now_unix_secs: u64,
request_dedup_key: Option<&str>,
) -> bool {
let request_dedup_key = request_dedup_key
.map(str::trim)
.filter(|value| !value.is_empty());
if let Some(request_dedup_key) = request_dedup_key {
if metadata
.get("image_quota_last_local_request_key")
.and_then(Value::as_str)
.is_some_and(|value| value == request_dedup_key)
{
return false;
}
}
let snapshot_window = chatgpt_web_image_quota_snapshot_window(status_snapshot);
let metadata_limit =
chatgpt_web_image_quota_f64(metadata.get("image_quota_total")).filter(|value| *value > 0.0);
let snapshot_limit = snapshot_window.and_then(|window| {
chatgpt_web_image_quota_f64(window.get("limit_value")).filter(|value| *value > 0.0)
});
let candidate_limit = metadata_limit.or(snapshot_limit);
let used = chatgpt_web_image_quota_f64(metadata.get("image_quota_used")).or_else(|| {
snapshot_window.and_then(|window| chatgpt_web_image_quota_f64(window.get("used_value")))
});
let remaining = chatgpt_web_image_quota_f64(metadata.get("image_quota_remaining"))
.or_else(|| {
snapshot_window
.and_then(|window| chatgpt_web_image_quota_f64(window.get("remaining_value")))
})
.or_else(|| {
candidate_limit
.zip(used)
.map(|(limit, used)| (limit - used).max(0.0))
});
let limit = chatgpt_web_image_quota_request_limit_choice(
metadata,
status_snapshot,
metadata_limit,
snapshot_limit,
remaining,
);
if limit.is_none()
&& chatgpt_web_image_quota_metadata_limit_is_legacy_free_default(
metadata,
status_snapshot,
metadata_limit,
remaining,
)
{
metadata.remove("image_quota_total");
metadata.remove("image_quota_limit_source");
}
let limit_value = limit
.as_ref()
.map(|limit| limit.value)
.unwrap_or_else(|| remaining.unwrap_or(0.0).max(0.0));
if limit_value > 0.0 {
metadata.insert("image_quota_total".to_string(), json!(limit_value));
if let Some(source) = limit
.as_ref()
.and_then(|limit| limit.source.as_deref())
.filter(|value| !value.is_empty())
{
metadata.insert("image_quota_limit_source".to_string(), json!(source));
}
}
match remaining {
Some(remaining) => {
let new_remaining = (remaining - 1.0).max(0.0);
metadata.insert("image_quota_remaining".to_string(), json!(new_remaining));
if limit_value > 0.0 {
metadata.insert(
"image_quota_used".to_string(),
json!((limit_value - new_remaining).max(0.0)),
);
} else if let Some(used) = used {
metadata.insert("image_quota_used".to_string(), json!(used + 1.0));
} else {
metadata.insert("image_quota_used".to_string(), json!(1.0));
}
}
None => {
let new_used = used.unwrap_or(0.0).max(0.0) + 1.0;
metadata.insert("image_quota_used".to_string(), json!(new_used));
if limit_value > 0.0 {
metadata.insert(
"image_quota_remaining".to_string(),
json!((limit_value - new_used).max(0.0)),
);
}
}
}
if !metadata.contains_key("image_quota_reset_at") {
if let Some(reset_at) =
snapshot_window.and_then(|window| chatgpt_web_image_quota_u64(window.get("reset_at")))
{
metadata.insert("image_quota_reset_at".to_string(), json!(reset_at));
}
}
metadata.insert("updated_at".to_string(), json!(now_unix_secs));
metadata.insert(
"image_quota_last_local_request_at".to_string(),
json!(now_unix_secs),
);
if let Some(request_dedup_key) = request_dedup_key {
metadata.insert(
"image_quota_last_local_request_key".to_string(),
json!(request_dedup_key),
);
}
let local_request_count =
chatgpt_web_image_quota_u64(metadata.get("image_quota_local_request_count")).unwrap_or(0);
metadata.insert(
"image_quota_local_request_count".to_string(),
json!(local_request_count.saturating_add(1)),
);
true
}
fn chatgpt_web_image_quota_request_delta_dedup_key(plan: &ExecutionPlan) -> Option<String> {
let request_id = plan.request_id.trim();
if request_id.is_empty() {
return None;
}
let candidate_id = plan
.candidate_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
Some(match candidate_id {
Some(candidate_id) => format!("{request_id}:{candidate_id}"),
None => request_id.to_string(),
})
}
#[derive(Debug, Clone)]
struct ChatGptWebImageQuotaRequestLimit {
value: f64,
source: Option<String>,
}
fn chatgpt_web_image_quota_metadata_limit_is_legacy_free_default(
metadata: &Map<String, Value>,
status_snapshot: Option<&Value>,
metadata_limit: Option<f64>,
remaining: Option<f64>,
) -> bool {
let Some(limit) = metadata_limit else {
return false;
};
let plan_type = chatgpt_web_image_quota_metadata_str(metadata, "plan_type").or_else(|| {
chatgpt_web_image_quota_snapshot(status_snapshot)
.and_then(|quota| chatgpt_web_image_quota_metadata_str(quota, "plan_type"))
});
let metadata_limit_source =
chatgpt_web_image_quota_metadata_str(metadata, "image_quota_limit_source");
chatgpt_web_image_quota_limit_is_legacy_free_default(
limit,
metadata_limit_source,
plan_type,
remaining,
)
}
fn chatgpt_web_image_quota_request_limit_choice(
metadata: &Map<String, Value>,
status_snapshot: Option<&Value>,
metadata_limit: Option<f64>,
snapshot_limit: Option<f64>,
remaining: Option<f64>,
) -> Option<ChatGptWebImageQuotaRequestLimit> {
let plan_type = chatgpt_web_image_quota_metadata_str(metadata, "plan_type").or_else(|| {
chatgpt_web_image_quota_snapshot(status_snapshot)
.and_then(|quota| chatgpt_web_image_quota_metadata_str(quota, "plan_type"))
});
let metadata_limit_source =
chatgpt_web_image_quota_metadata_str(metadata, "image_quota_limit_source");
if let Some(limit) = metadata_limit {
if !chatgpt_web_image_quota_limit_is_legacy_free_default(
limit,
metadata_limit_source,
plan_type,
remaining,
) {
let source = metadata_limit_source.map(ToOwned::to_owned).or_else(|| {
let is_first_remaining = plan_type
.is_some_and(|value| value.eq_ignore_ascii_case("free"))
&& remaining.is_some_and(|remaining| (limit - remaining).abs() <= f64::EPSILON);
Some(
if is_first_remaining {
"first_remaining"
} else {
"stored"
}
.to_string(),
)
});
return Some(ChatGptWebImageQuotaRequestLimit {
value: limit,
source,
});
}
}
if let Some(limit) = snapshot_limit {
if !chatgpt_web_image_quota_limit_is_legacy_free_default(limit, None, plan_type, remaining)
{
return Some(ChatGptWebImageQuotaRequestLimit {
value: limit,
source: Some("status_snapshot".to_string()),
});
}
}
remaining
.filter(|remaining| remaining.is_finite() && *remaining > 0.0)
.map(|remaining| ChatGptWebImageQuotaRequestLimit {
value: remaining,
source: Some("first_remaining".to_string()),
})
}
fn chatgpt_web_image_quota_metadata_str<'a>(
metadata: &'a Map<String, Value>,
key: &str,
) -> Option<&'a str> {
metadata
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
}
fn chatgpt_web_image_quota_limit_is_legacy_free_default(
limit: f64,
source: Option<&str>,
plan_type: Option<&str>,
remaining: Option<f64>,
) -> bool {
let plan_type_is_free = plan_type
.map(str::trim)
.is_some_and(|value| value.eq_ignore_ascii_case("free"));
if !plan_type_is_free || source.is_some() {
return false;
}
if (limit - 25.0).abs() > f64::EPSILON {
return false;
}
remaining.is_none_or(|remaining| remaining.is_finite() && remaining < limit)
}
async fn refresh_chatgpt_web_image_quota_after_success(
state: &AppState,
plan: &ExecutionPlan,
base_url: &str,
token: &str,
) -> Result<bool, String> {
let key_id = plan.key_id.trim();
let provider_id = plan.provider_id.trim();
let key_ids = [key_id.to_string()];
let provider_ids = [provider_id.to_string()];
let key_available = state
.read_provider_catalog_keys_by_ids(&key_ids)
.await
.map_err(|_| "ChatGPT-Web quota key read failed".to_string())?
.into_iter()
.any(|key| key.id == key_id && key.provider_id == provider_id);
if !key_available {
return Ok(false);
}
let Some(provider) = state
.read_provider_catalog_providers_by_ids(&provider_ids)
.await
.map_err(|_| "ChatGPT-Web quota provider read failed".to_string())?
.into_iter()
.find(|provider| provider.id == provider_id)
else {
return Ok(false);
};
if !provider
.provider_type
.trim()
.eq_ignore_ascii_case("chatgpt_web")
{
return Ok(false);
}
let authorization = (
"authorization".to_string(),
format!("Bearer {}", token.trim()),
);
let spec = build_chatgpt_web_pool_quota_request(key_id, base_url, authorization);
let quota_plan = build_chatgpt_web_image_quota_refresh_plan(plan, spec);
let result = DirectSyncExecutionRuntime::new()
.execute_sync(&quota_plan)
.await
.map_err(|_| "ChatGPT-Web quota refresh request failed".to_string())?;
if result.status_code != 200 {
return Err(format!(
"ChatGPT-Web quota refresh returned HTTP {}",
result.status_code
));
}
let body_json = execution_result_json(&result)
.map_err(|_| "ChatGPT-Web quota refresh response was invalid".to_string())?;
let now_unix_secs = current_unix_secs();
let Some(metadata) = parse_chatgpt_web_conversation_init_response(&body_json, now_unix_secs)
else {
return Ok(false);
};
let Some(latest_key) = state
.read_provider_catalog_keys_by_ids(&key_ids)
.await
.map_err(|_| "ChatGPT-Web quota key read failed".to_string())?
.into_iter()
.find(|key| key.id == key_id && key.provider_id == provider_id)
else {
return Ok(false);
};
let expected_namespace_value = latest_key
.upstream_metadata
.as_ref()
.and_then(Value::as_object)
.and_then(|metadata| metadata.get("chatgpt_web"))
.cloned();
let mut metadata = metadata.clone();
normalize_chatgpt_web_image_quota_limit(&mut metadata, latest_key.upstream_metadata.as_ref());
metadata = admin_provider_metadata_bucket_safe_json("chatgpt_web", Some(&metadata));
let mut updated_key = latest_key;
let namespace_value = metadata.clone();
let updated_upstream_metadata = merge_provider_metadata_object(
updated_key.upstream_metadata.as_ref(),
"chatgpt_web",
metadata,
);
updated_key.upstream_metadata = updated_upstream_metadata;
let (oauth_invalid_at_unix_secs, oauth_invalid_reason) =
quota_refresh_success_invalid_state(&updated_key);
updated_key.oauth_invalid_at_unix_secs = oauth_invalid_at_unix_secs;
updated_key.oauth_invalid_reason = oauth_invalid_reason;
updated_key.status_snapshot = sync_provider_key_quota_status_snapshot(
updated_key.status_snapshot.as_ref(),
"chatgpt_web",
updated_key.upstream_metadata.as_ref(),
"image_success",
);
updated_key.status_snapshot =
sync_provider_key_oauth_status_snapshot(updated_key.status_snapshot.as_ref(), &updated_key);
updated_key.updated_at_unix_secs = Some(now_unix_secs);
let persisted = state
.update_provider_catalog_key_runtime_metadata(&ProviderCatalogKeyRuntimeMetadataUpdate {
key_id: updated_key.id.clone(),
namespace: "chatgpt_web".to_string(),
expected_upstream_metadata_value: expected_namespace_value,
upstream_metadata_value: namespace_value,
status_snapshot_patch: provider_operational_status_patch(
updated_key.status_snapshot.as_ref(),
),
updated_at_unix_secs: updated_key.updated_at_unix_secs,
})
.await
.map_err(|_| "ChatGPT-Web quota state update failed".to_string())?;
if persisted {
return state
.update_provider_catalog_key_oauth_runtime_state(
&updated_key.id,
updated_key.oauth_invalid_at_unix_secs,
updated_key.oauth_invalid_reason.as_deref(),
updated_key.updated_at_unix_secs,
)
.await
.map_err(|_| "ChatGPT-Web OAuth state update failed".to_string());
}
// The conversation/init response is an authoritative snapshot. A
// conflict means a newer local delta won; do not overwrite it with
// this stale response. The next refresh will observe the new value.
Ok(false)
}
fn build_chatgpt_web_image_quota_refresh_plan(
plan: &ExecutionPlan,
spec: ProviderPoolQuotaRequestSpec,
) -> ExecutionPlan {
let ProviderPoolQuotaRequestSpec {
request_id,
provider_name,
quota_kind: _,
method,
url,
headers,
content_type,
json_body,
client_api_format,
provider_api_format,
model_name,
} = spec;
let body = json_body
.map(RequestBody::from_json)
.unwrap_or(RequestBody {
json_body: None,
body_bytes_b64: None,
body_ref: None,
});
ExecutionPlan {
request_id,
candidate_id: plan.candidate_id.clone(),
provider_name: Some(provider_name),
provider_id: plan.provider_id.clone(),
endpoint_id: plan.endpoint_id.clone(),
key_id: plan.key_id.clone(),
method,
url,
headers,
content_type,
content_encoding: None,
body,
stream: false,
client_api_format,
provider_api_format,
model_name,
proxy: plan.proxy.clone(),
transport_profile: chatgpt_web_image_transport_profile(plan),
timeouts: Some(chatgpt_web_image_quota_refresh_timeouts(
plan.proxy.as_ref(),
)),
}
}
fn chatgpt_web_image_quota_refresh_timeouts(proxy: Option<&ProxySnapshot>) -> ExecutionTimeouts {
let timeout_ms = if proxy.is_some() {
CHATGPT_WEB_QUOTA_REFRESH_PROXY_TIMEOUT_MS
} else {
CHATGPT_WEB_QUOTA_REFRESH_TIMEOUT_MS
};
ExecutionTimeouts {
connect_ms: Some(timeout_ms),
read_ms: Some(timeout_ms),
write_ms: Some(timeout_ms),
pool_ms: Some(timeout_ms),
total_ms: Some(timeout_ms),
..ExecutionTimeouts::default()
}
}
fn merge_provider_metadata_object(
current: Option<&Value>,
section_key: &str,
section_value: Value,
) -> Option<Value> {
let mut merged = current
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
merged.insert(section_key.to_string(), section_value);
Some(Value::Object(merged))
}
fn provider_operational_status_patch(status_snapshot: Option<&Value>) -> Value {
let mut patch = Map::new();
if let Some(snapshot) = status_snapshot.and_then(Value::as_object) {
for field in ["quota", "oauth"] {
if let Some(value) = snapshot.get(field) {
patch.insert(field.to_string(), value.clone());
}
}
}
Value::Object(patch)
}
fn chatgpt_web_image_quota_snapshot_window(
status_snapshot: Option<&Value>,
) -> Option<&Map<String, Value>> {
let quota = chatgpt_web_image_quota_snapshot(status_snapshot)?;
quota
.get("windows")
.and_then(Value::as_array)?
.iter()
.filter_map(Value::as_object)
.find(|window| {
window
.get("code")
.and_then(Value::as_str)
.is_some_and(|value| value.trim().eq_ignore_ascii_case("image_gen"))
})
.or_else(|| {
quota
.get("windows")
.and_then(Value::as_array)?
.iter()
.filter_map(Value::as_object)
.find(|window| {
window
.get("scope")
.and_then(Value::as_str)
.is_some_and(|value| value.trim().eq_ignore_ascii_case("account"))
})
})
}
fn chatgpt_web_image_quota_snapshot(
status_snapshot: Option<&Value>,
) -> Option<&Map<String, Value>> {
let quota = status_snapshot
.and_then(Value::as_object)
.and_then(|snapshot| snapshot.get("quota"))
.and_then(Value::as_object)?;
if quota
.get("provider_type")
.and_then(Value::as_str)
.is_some_and(|value| !value.trim().eq_ignore_ascii_case("chatgpt_web"))
{
return None;
}
Some(quota)
}
fn chatgpt_web_image_quota_f64(value: Option<&Value>) -> Option<f64> {
match value {
Some(Value::Number(number)) => number.as_f64(),
Some(Value::String(value)) => value.trim().parse::<f64>().ok(),
_ => None,
}
.filter(|value| value.is_finite())
}
fn chatgpt_web_image_quota_u64(value: Option<&Value>) -> Option<u64> {
let mut parsed = chatgpt_web_image_quota_f64(value)?;
if parsed <= 0.0 {
return None;
}
if parsed > 1_000_000_000_000.0 {
parsed /= 1000.0;
}
Some(parsed.floor() as u64)
}
fn chatgpt_web_image_transport_profile(plan: &ExecutionPlan) -> Option<ResolvedTransportProfile> {
match plan.transport_profile.as_ref() {
Some(profile)
if profile
.backend
.trim()
.eq_ignore_ascii_case(TRANSPORT_BACKEND_BROWSER_WREQ) =>
{
Some(profile.clone())
}
_ => Some(default_chatgpt_web_image_transport_profile()),
}
}
fn default_chatgpt_web_image_transport_profile() -> ResolvedTransportProfile {
ResolvedTransportProfile {
profile_id: CHATGPT_WEB_BROWSER_PROFILE.to_string(),
backend: TRANSPORT_BACKEND_BROWSER_WREQ.to_string(),
http_mode: TRANSPORT_HTTP_MODE_AUTO.to_string(),
pool_scope: TRANSPORT_POOL_SCOPE_KEY.to_string(),
header_fingerprint: None,
extra: Some(json!({
"browser_profile": CHATGPT_WEB_BROWSER_PROFILE,
"source": "chatgpt_web_image_default",
})),
}
}
fn web_base_headers(fp: &WebFingerprint, token: &str, path: &str) -> BTreeMap<String, String> {
let mut headers = BTreeMap::from([
("user-agent".to_string(), fp.user_agent.to_string()),
(
"origin".to_string(),
CHATGPT_WEB_DEFAULT_BASE_URL.to_string(),
),
(
"referer".to_string(),
format!("{CHATGPT_WEB_DEFAULT_BASE_URL}/"),
),
(
"accept-language".to_string(),
"zh-CN,zh;q=0.9,en;q=0.8,en-US;q=0.7".to_string(),
),
("cache-control".to_string(), "no-cache".to_string()),
("pragma".to_string(), "no-cache".to_string()),
("priority".to_string(), "u=1, i".to_string()),
("sec-ch-ua".to_string(), CHATGPT_WEB_SEC_CH_UA.to_string()),
("sec-ch-ua-arch".to_string(), r#""x86""#.to_string()),
("sec-ch-ua-bitness".to_string(), r#""64""#.to_string()),
("sec-ch-ua-mobile".to_string(), "?0".to_string()),
("sec-ch-ua-model".to_string(), r#""""#.to_string()),
("sec-ch-ua-platform".to_string(), r#""Windows""#.to_string()),
(
"sec-ch-ua-platform-version".to_string(),
r#""19.0.0""#.to_string(),
),
("sec-fetch-dest".to_string(), "empty".to_string()),
("sec-fetch-mode".to_string(), "cors".to_string()),
("sec-fetch-site".to_string(), "same-origin".to_string()),
("oai-device-id".to_string(), fp.device_id.clone()),
("oai-session-id".to_string(), fp.session_id.clone()),
("oai-language".to_string(), "zh-CN".to_string()),
(
"oai-client-version".to_string(),
CHATGPT_WEB_CLIENT_VERSION.to_string(),
),
(
"oai-client-build-number".to_string(),
CHATGPT_WEB_BUILD_NUMBER.to_string(),
),
]);
if !path.is_empty() {
headers.insert("x-openai-target-path".to_string(), path.to_string());
headers.insert("x-openai-target-route".to_string(), path.to_string());
}
if !token.trim().is_empty() {
headers.insert(
"authorization".to_string(),
format!("Bearer {}", token.trim()),
);
}
headers
}
fn web_image_headers(
fp: &WebFingerprint,
token: &str,
path: &str,
requirements: &WebRequirement,
conduit: Option<&str>,
accept: &str,
) -> BTreeMap<String, String> {
let mut headers = web_base_headers(fp, token, path);
headers.insert("content-type".to_string(), "application/json".to_string());
headers.insert("accept".to_string(), accept.to_string());
headers.insert(
"openai-sentinel-chat-requirements-token".to_string(),
requirements.token.clone(),
);
if let Some(proof_token) = requirements.proof_token.as_ref() {
headers.insert(
"openai-sentinel-proof-token".to_string(),
proof_token.clone(),
);
}
if let Some(so_token) = requirements.so_token.as_ref() {
headers.insert("openai-sentinel-so-token".to_string(), so_token.clone());
}
if let Some(conduit) = conduit.map(str::trim).filter(|value| !value.is_empty()) {
headers.insert("x-conduit-token".to_string(), conduit.to_string());
}
if accept == "text/event-stream" {
headers.insert(
"x-oai-turn-trace-id".to_string(),
Uuid::new_v4().to_string(),
);
}
headers
}
fn web_image_message_content(prompt: &str, uploads: &[WebUploadMeta]) -> (Value, Value) {
if uploads.is_empty() {
return (
json!({"content_type": "text", "parts": [prompt]}),
json!({
"developer_mode_connector_ids": [],
"selected_github_repos": [],
"selected_all_github_repos": false,
"system_hints": ["picture_v2"],
"serialization_metadata": {"custom_symbol_offsets": []}
}),
);
}
let mut parts = Vec::new();
let mut attachments = Vec::new();
for upload in uploads {
parts.push(json!({
"content_type": "image_asset_pointer",
"asset_pointer": format!("sediment://file_{}", upload.file_id.trim_start_matches("file_")),
"width": upload.width.unwrap_or(1024),
"height": upload.height.unwrap_or(1024),
"size_bytes": upload.file_size
}));
let mut attachment = json!({
"id": upload.file_id,
"mime_type": upload.mime,
"name": upload.file_name,
"size": upload.file_size,
"width": upload.width.unwrap_or(1024),
"height": upload.height.unwrap_or(1024),
"source": "library",
"is_big_paste": false
});
if let Some(library_file_id) = upload.library_file_id.as_ref() {
attachment["library_file_id"] = Value::String(library_file_id.clone());
}
attachments.push(attachment);
}
parts.push(Value::String(prompt.to_string()));
(
json!({"content_type": "multimodal_text", "parts": parts}),
json!({
"developer_mode_connector_ids": [],
"selected_github_repos": [],
"selected_all_github_repos": false,
"system_hints": ["picture_v2"],
"serialization_metadata": {"custom_symbol_offsets": []},
"attachments": attachments
}),
)
}
fn parse_web_image_sse(bytes: &[u8]) -> WebImageSseSummary {
let text = String::from_utf8_lossy(bytes);
let mut summary = WebImageSseSummary::default();
let mut data_lines = Vec::new();
for line in text.lines() {
let line = line.trim_end_matches('\r');
if line.is_empty() {
flush_sse_data(&mut data_lines, &mut summary);
continue;
}
if let Some(data) = line.strip_prefix("data:") {
data_lines.push(data.trim().to_string());
}
}
flush_sse_data(&mut data_lines, &mut summary);
summary
}
fn flush_sse_data(data_lines: &mut Vec<String>, summary: &mut WebImageSseSummary) {
if data_lines.is_empty() {
return;
}
let data = data_lines.join("\n");
data_lines.clear();
if data.trim().is_empty() || data.trim() == "[DONE]" {
return;
}
if let Ok(value) = serde_json::from_str::<Value>(&data) {
if matches!(
value.get("type").and_then(Value::as_str),
Some("error" | "response.failed")
) {
summary.failure = Some(bounded_web_failure_value(&value));
}
if let Some(text) = extract_assistant_text(&value) {
summary.last_text = Some(text);
}
if let Some(item) = value.get("item").filter(|item| {
item.get("type").and_then(Value::as_str) == Some("image_generation_call")
}) {
// Keep the provider's declared output format when constructing a
// data URL. The bytes are still verified by `parse_data_url`
// before download, but labelling every output as PNG would create
// an avoidable MIME/signature mismatch (and an extra failed
// download attempt) for JPEG/WebP results.
if let Some(result) = item
.get("result")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
let mime = mime_for_web_output_format(
item.get("output_format")
.and_then(Value::as_str)
.unwrap_or_default(),
);
if let Some(url) = bounded_web_image_data_url(mime, result) {
summary.add_values(WebImageSummaryCollection::DirectUrl, [url]);
}
}
}
summary.add_values(
WebImageSummaryCollection::DirectUrl,
extract_web_image_payload_urls(&value),
);
extract_web_image_values(&value, summary);
}
}
fn extract_web_image_payload_urls(value: &Value) -> Vec<String> {
let mut urls = Vec::new();
match value.get("type").and_then(Value::as_str) {
Some("response.output_item.done") => {
if let Some(item) = value.get("item") {
add_web_output_item_image_url(&mut urls, item);
}
}
Some("response.completed") => {
if let Some(output) = value
.get("response")
.and_then(|response| response.get("output"))
.or_else(|| value.get("output"))
.and_then(Value::as_array)
{
for item in output {
add_web_output_item_image_url(&mut urls, item);
}
}
}
Some("response.image_generation_call.partial_image") => {
if let Some(partial_b64) = value
.get("partial_image_b64")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
let mime = mime_for_web_output_format(
value
.get("output_format")
.and_then(Value::as_str)
.unwrap_or_default(),
);
if let Some(url) = bounded_web_image_data_url(mime, partial_b64) {
add_unique_values(&mut urls, [url]);
}
}
}
_ => {
if value.get("item").is_some() {
if let Some(item) = value.get("item") {
add_web_output_item_image_url(&mut urls, item);
}
}
if let Some(output) = value.get("output").and_then(Value::as_array) {
for item in output {
add_web_output_item_image_url(&mut urls, item);
}
}
}
}
urls
}
fn add_web_output_item_image_url(urls: &mut Vec<String>, item: &Value) {
if item.get("type").and_then(Value::as_str) != Some("image_generation_call") {
return;
}
if let Some(url) = web_output_item_url(item) {
add_unique_values(urls, [url]);
}
}
fn web_output_item_url(item: &Value) -> Option<String> {
if let Some(url) = image_payload_url_from_object(item) {
return Some(url);
}
item.get("content")
.and_then(Value::as_array)
.into_iter()
.flatten()
.find_map(image_payload_url_from_object)
}
fn image_payload_url_from_object(value: &Value) -> Option<String> {
if let Some(url) = value
.get("url")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
if url.len() > CHATGPT_WEB_IMAGE_MAX_EXTERNAL_URL_BYTES {
return None;
}
return Some(url.to_string());
}
let b64 = value
.get("result")
.or_else(|| value.get("b64_json"))
.or_else(|| value.get("image_b64"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let mime = mime_for_web_output_format(
value
.get("output_format")
.and_then(Value::as_str)
.unwrap_or_default(),
);
bounded_web_image_data_url(mime, b64)
}
fn bounded_web_image_data_url(mime: &str, b64: &str) -> Option<String> {
let b64 = b64.trim();
if b64.is_empty()
|| b64.len()
> maximum_base64_len_for_decoded_limit(chatgpt_web_image_raw_payload_limit_bytes())
{
return None;
}
let prefix_len = "data:;base64,".len().saturating_add(mime.len());
if prefix_len.saturating_add(b64.len()) > chatgpt_web_image_sse_envelope_limit_bytes() {
return None;
}
Some(format!("data:{mime};base64,{b64}"))
}
fn mime_for_web_output_format(format: &str) -> &'static str {
let format = format.trim();
if format.eq_ignore_ascii_case("jpeg") || format.eq_ignore_ascii_case("jpg") {
"image/jpeg"
} else if format.eq_ignore_ascii_case("webp") {
"image/webp"
} else {
"image/png"
}
}
fn extract_web_image_values(value: &Value, summary: &mut WebImageSseSummary) {
match value {
Value::Object(object) => {
for (key, value) in object {
if key == "conversation_id" {
if let Some(conversation_id) = value
.as_str()
.map(str::trim)
.filter(|value| web_opaque_id_is_safe(value))
{
summary
.conversation_id
.get_or_insert(conversation_id.to_string());
}
}
extract_web_image_values(value, summary);
}
}
Value::Array(values) => {
for value in values {
extract_web_image_values(value, summary);
}
}
Value::String(text) => {
let text = text.trim();
if let Some(sediment_id) = text.strip_prefix("sediment://") {
if web_opaque_id_is_safe(sediment_id) {
summary.add_values(
WebImageSummaryCollection::SedimentId,
[sediment_id.to_string()],
);
}
} else if is_web_file_id(text) {
summary.add_values(WebImageSummaryCollection::FileId, [text.to_string()]);
} else if (text.len() <= CHATGPT_WEB_IMAGE_MAX_EXTERNAL_URL_BYTES
&& is_generated_web_asset_url(text))
|| (text.len() <= chatgpt_web_image_sse_envelope_limit_bytes()
&& is_data_image_reference(text))
{
summary.add_values(WebImageSummaryCollection::DirectUrl, [text.to_string()]);
}
}
_ => {}
}
}
fn extract_assistant_text(value: &Value) -> Option<String> {
value
.get("message")
.and_then(|message| message.get("content"))
.and_then(|content| content.get("parts"))
.and_then(Value::as_array)
.and_then(|parts| parts.iter().filter_map(Value::as_str).next())
.map(str::trim)
.filter(|value| {
!value.is_empty() && value.len() <= CHATGPT_WEB_IMAGE_SUMMARY_MAX_TEXT_BYTES
})
.map(ToOwned::to_owned)
}
fn bounded_web_failure_value(value: &Value) -> Value {
if json_value_fits_serialized_limit(value, CHATGPT_WEB_IMAGE_SUMMARY_MAX_TEXT_BYTES) {
return value.clone();
}
if value.get("type").and_then(Value::as_str) == Some("response.failed") {
json!({
"type": "response.failed",
"response": {
"status": "failed",
"error": {
"code": "chatgpt_web_image_failed",
"message": "ChatGPT-Web image provider returned an oversized failure"
}
}
})
} else {
json!({
"type": "error",
"error": {
"code": "chatgpt_web_image_failed",
"message": "ChatGPT-Web image provider returned an oversized failure"
}
})
}
}
fn merge_web_summary(target: &mut WebImageSseSummary, source: &mut WebImageSseSummary) {
if target.conversation_id.is_none() {
target.conversation_id = source
.conversation_id
.take()
.filter(|value| web_opaque_id_is_safe(value));
}
target.add_values(
WebImageSummaryCollection::FileId,
source
.file_ids
.drain(..)
.filter(|value| is_web_file_id(value)),
);
target.add_values(
WebImageSummaryCollection::SedimentId,
source
.sediment_ids
.drain(..)
.filter(|value| web_opaque_id_is_safe(value)),
);
target.add_values(
WebImageSummaryCollection::DirectUrl,
source.direct_urls.drain(..),
);
if target.failure.is_none() {
target.failure = source.failure.take();
}
if target.last_text.is_none() {
target.last_text = source.last_text.take();
}
}
fn filter_uploaded_asset_ids(summary: &mut WebImageSseSummary, uploads: &[WebUploadMeta]) {
let uploaded = uploaded_file_ids(uploads);
summary.file_ids.retain(|id| !uploaded.contains(id));
summary.sediment_ids.retain(|id| !uploaded.contains(id));
}
fn uploaded_file_ids(uploads: &[WebUploadMeta]) -> BTreeSet<String> {
uploads
.iter()
.flat_map(|upload| {
[Some(upload.file_id.clone()), upload.library_file_id.clone()]
.into_iter()
.flatten()
})
.collect()
}
fn add_unique_values(values: &mut Vec<String>, incoming: impl IntoIterator<Item = String>) {
let budget = chatgpt_web_image_sse_envelope_limit_bytes();
let mut retained_bytes = saturating_string_bytes(values);
for value in incoming {
if value.is_empty()
|| value.len() > budget
|| values.len() >= CHATGPT_WEB_IMAGE_SUMMARY_MAX_DIRECT_URLS
|| value.len() > budget.saturating_sub(retained_bytes)
|| values.iter().any(|existing| existing == &value)
{
continue;
}
retained_bytes = retained_bytes.saturating_add(value.len());
values.push(value);
}
}
fn build_success_sse(
request: &ChatGptWebImageRequest,
image: &DownloadedImage,
report_context: Option<&Value>,
) -> String {
let response_id = format!("resp_{}", Uuid::new_v4().simple());
let item_id = format!("ig_{}", Uuid::new_v4().simple());
let created_at = current_unix_secs() as i64;
let output_format = output_format_from_mime(&image.mime, request.output_format.as_str());
let usage = chatgpt_web_image_usage(request, image, report_context);
let item = json!({
"id": item_id,
"type": "image_generation_call",
"result": image.b64_json,
"output_format": output_format,
"width": image.width,
"height": image.height,
"revised_prompt": Value::Null
});
let created = json!({
"type": "response.created",
"response": {
"id": response_id,
"object": "response",
"created_at": created_at,
"model": request.model,
"status": "in_progress"
}
});
let done = json!({
"type": "response.output_item.done",
"output_index": 0,
"item": item
});
let completed = json!({
"type": "response.completed",
"response": {
"id": response_id,
"object": "response",
"created_at": created_at,
"model": request.model,
"status": "completed",
"output": [{
"type": "image_generation_call",
"output_format": output_format,
"width": image.width,
"height": image.height,
"revised_prompt": Value::Null
}],
"usage": usage.0,
"tool_usage": usage.1
}
});
format!(
"event: response.created\ndata: {}\n\nevent: response.output_item.done\ndata: {}\n\nevent: response.completed\ndata: {}\n\ndata: [DONE]\n\n",
created, done, completed
)
}
fn chatgpt_web_image_usage(
request: &ChatGptWebImageRequest,
image: &DownloadedImage,
report_context: Option<&Value>,
) -> (Value, Value) {
let input_tokens = chatgpt_web_image_input_tokens(request, report_context);
let estimated_output_tokens = chatgpt_web_image_output_tokens(request, image, report_context);
let usage = json!({
"input_tokens": input_tokens,
"output_tokens": estimated_output_tokens,
"total_tokens": input_tokens.saturating_add(estimated_output_tokens),
});
let tool_usage = json!({
"image_gen": {
"input_tokens": input_tokens,
"input_tokens_details": {
"image_tokens": 0,
"text_tokens": input_tokens
},
"output_tokens": estimated_output_tokens,
"output_tokens_details": {
"image_tokens": estimated_output_tokens,
"text_tokens": 0
},
"total_tokens": input_tokens.saturating_add(estimated_output_tokens),
}
});
(usage, tool_usage)
}
fn chatgpt_web_image_input_tokens(
request: &ChatGptWebImageRequest,
report_context: Option<&Value>,
) -> u64 {
let prompt = chatgpt_web_image_prompt_text(request, report_context);
estimate_text_tokens(prompt.as_str())
}
fn chatgpt_web_image_output_tokens(
request: &ChatGptWebImageRequest,
image: &DownloadedImage,
report_context: Option<&Value>,
) -> u64 {
let quality = chatgpt_web_image_quality(request, report_context);
let size = chatgpt_web_image_size(request, image, report_context);
let partial_images = chatgpt_web_image_partial_images(request, report_context);
let base_tokens = size
.map(|(width, height)| gpt_image2_output_tokens(width, height, quality.as_str()))
.unwrap_or_else(|| gpt_image2_output_tokens(1024, 1024, quality.as_str()));
base_tokens
.saturating_add(partial_images.saturating_mul(GPT_IMAGE2_PARTIAL_IMAGE_OUTPUT_TOKENS))
}
fn chatgpt_web_image_quality(
request: &ChatGptWebImageRequest,
report_context: Option<&Value>,
) -> String {
let candidate = [
chatgpt_web_report_context_image_request_text(report_context, "quality"),
chatgpt_web_report_context_original_request_text(report_context, "quality"),
request.quality.clone(),
]
.into_iter()
.flatten()
.find(|value| !value.is_empty())
.unwrap_or_else(|| "medium".to_string());
normalize_gpt_image2_quality(candidate.as_str())
}
fn chatgpt_web_image_size(
request: &ChatGptWebImageRequest,
image: &DownloadedImage,
report_context: Option<&Value>,
) -> Option<(u64, u64)> {
if let Some(candidate) = downloaded_image_dimensions(image)
.filter(|(width, height)| gpt_image2_dimensions_are_plausible(*width, *height))
{
return Some(candidate);
}
let candidates = [
chatgpt_web_report_context_image_request_text(report_context, "size")
.and_then(|value| parse_gpt_image2_size(value.as_str())),
chatgpt_web_report_context_original_request_text(report_context, "size")
.and_then(|value| parse_gpt_image2_size(value.as_str())),
parse_gpt_image2_size(request.size.as_str()),
];
for candidate in candidates.into_iter().flatten() {
if gpt_image2_dimensions_are_valid(candidate.0, candidate.1) {
return Some(candidate);
}
}
let ratio = chatgpt_web_image_ratio(request, report_context);
Some(chatgpt_web_fallback_size_for_ratio(ratio.as_str()))
}
fn chatgpt_web_image_partial_images(
request: &ChatGptWebImageRequest,
report_context: Option<&Value>,
) -> u64 {
chatgpt_web_report_context_image_request_u64(report_context, "partial_images")
.or_else(|| {
chatgpt_web_report_context_original_request_u64(report_context, "partial_images")
})
.unwrap_or(request.partial_images)
}
fn chatgpt_web_image_ratio(
request: &ChatGptWebImageRequest,
report_context: Option<&Value>,
) -> String {
chatgpt_web_report_context_image_request_text(report_context, "ratio")
.or_else(|| chatgpt_web_report_context_original_request_text(report_context, "ratio"))
.or_else(|| {
chatgpt_web_report_context_original_request_text(report_context, "aspect_ratio")
})
.unwrap_or_else(|| request.ratio.clone())
}
fn chatgpt_web_report_context_image_request_text(
report_context: Option<&Value>,
key: &str,
) -> Option<String> {
report_context
.and_then(|value| value.get("image_request"))
.and_then(|value| value.get(key))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn chatgpt_web_report_context_image_request_u64(
report_context: Option<&Value>,
key: &str,
) -> Option<u64> {
report_context
.and_then(|value| value.get("image_request"))
.and_then(|value| value.get(key))
.and_then(|value| json_u64(Some(value)))
}
fn chatgpt_web_report_context_original_request_text(
report_context: Option<&Value>,
key: &str,
) -> Option<String> {
let original = report_context?.get("original_request_body")?;
value_text(original.get(key)).or_else(|| {
chatgpt_web_original_image_tool_value(original, key)
.and_then(|value| value_text(Some(value)))
})
}
fn chatgpt_web_report_context_original_request_u64(
report_context: Option<&Value>,
key: &str,
) -> Option<u64> {
let original = report_context?.get("original_request_body")?;
json_u64(original.get(key)).or_else(|| {
chatgpt_web_original_image_tool_value(original, key).and_then(|value| json_u64(Some(value)))
})
}
fn chatgpt_web_original_image_tool_value<'a>(original: &'a Value, key: &str) -> Option<&'a Value> {
original
.get("tools")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter(|tool| {
tool.get("type")
.and_then(Value::as_str)
.is_some_and(|value| value.trim().eq_ignore_ascii_case("image_generation"))
})
.find_map(|tool| tool.get(key))
}
fn chatgpt_web_image_prompt_text(
request: &ChatGptWebImageRequest,
report_context: Option<&Value>,
) -> String {
chatgpt_web_report_context_original_request_text(report_context, "prompt")
.or_else(|| chatgpt_web_report_context_image_request_text(report_context, "prompt"))
.unwrap_or_else(|| request.prompt.clone())
}
fn value_text(value: Option<&Value>) -> Option<String> {
value
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn downloaded_image_dimensions(image: &DownloadedImage) -> Option<(u64, u64)> {
match (image.width, image.height) {
(Some(width), Some(height)) if width > 0 && height > 0 => {
Some((width as u64, height as u64))
}
_ => None,
}
}
fn gpt_image2_dimensions_are_plausible(width: u64, height: u64) -> bool {
let pixels = width.saturating_mul(height);
if !(GPT_IMAGE2_TOKEN_MIN_PIXELS..=GPT_IMAGE2_TOKEN_MAX_PIXELS).contains(&pixels) {
return false;
}
let max_edge = width.max(height);
let min_edge = width.min(height);
if max_edge > GPT_IMAGE2_TOKEN_MAX_EDGE {
return false;
}
if max_edge > min_edge.saturating_mul(GPT_IMAGE2_TOKEN_MAX_ASPECT_RATIO) {
return false;
}
true
}
fn gpt_image2_dimensions_are_valid(width: u64, height: u64) -> bool {
width.is_multiple_of(16)
&& height.is_multiple_of(16)
&& gpt_image2_dimensions_are_plausible(width, height)
}
fn normalize_gpt_image2_quality(value: &str) -> String {
match value.trim().to_ascii_lowercase().as_str() {
"low" => "low".to_string(),
"medium" | "standard" | "auto" => "medium".to_string(),
"high" | "hd" => "high".to_string(),
_ => "medium".to_string(),
}
}
fn parse_gpt_image2_size(size: &str) -> Option<(u64, u64)> {
let normalized = size.trim().to_ascii_lowercase().replace('×', "x");
let (width, height) = normalized.split_once('x')?;
let width = width
.trim()
.parse::<u64>()
.ok()
.filter(|value| *value > 0)?;
let height = height
.trim()
.parse::<u64>()
.ok()
.filter(|value| *value > 0)?;
Some((width, height))
}
fn chatgpt_web_fallback_size_for_ratio(ratio: &str) -> (u64, u64) {
match ratio.trim() {
"3:2" => (1216, 832),
"2:3" => (832, 1216),
"4:3" => (1152, 864),
"3:4" => (864, 1152),
"5:4" => (1120, 896),
"4:5" => (896, 1120),
"16:9" => (1344, 768),
"9:16" => (768, 1344),
"21:9" => (1536, 640),
_ => (1024, 1024),
}
}
// Estimate GPT Image 2 image-token output using the same dimensions and quality
// drivers as OpenAI's public cost calculator. This intentionally ignores the
// base64 response length, which is only a transport encoding.
fn gpt_image2_output_tokens(width: u64, height: u64, quality: &str) -> u64 {
let quality_scale = match quality.trim().to_ascii_lowercase().as_str() {
"low" => 16u64,
"high" => 96u64,
_ => 48u64,
};
let long = width.max(height);
let short = width.min(height);
let short_scale = round_div_u64(quality_scale.saturating_mul(short), long);
let (long_scale, short_scale) = if width >= height {
(quality_scale, short_scale)
} else {
(short_scale, quality_scale)
};
let latent_pixels = u128::from(long_scale).saturating_mul(u128::from(short_scale));
let image_pixels = u128::from(width).saturating_mul(u128::from(height));
let numerator =
latent_pixels.saturating_mul(u128::from(2_000_000u64).saturating_add(image_pixels));
let tokens = (numerator.saturating_add(4_000_000u128 - 1)) / 4_000_000u128;
u64::try_from(tokens).unwrap_or(u64::MAX)
}
fn round_div_u64(numerator: u64, denominator: u64) -> u64 {
if denominator == 0 {
return 0;
}
numerator.saturating_add(denominator / 2) / denominator
}
fn estimate_text_tokens(text: &str) -> u64 {
let chars = text.chars().count() as u64;
if chars == 0 {
0
} else {
chars.div_ceil(4).max(1)
}
}
fn json_u64(value: Option<&Value>) -> Option<u64> {
value.and_then(|value| {
value
.as_u64()
.or_else(|| {
value
.as_i64()
.and_then(|number| (number >= 0).then_some(number as u64))
})
.or_else(|| {
value
.as_str()
.and_then(|number| number.trim().parse::<u64>().ok())
})
})
}
fn chatgpt_web_image_operation(value: Option<&Value>) -> String {
let Some(value) = value.and_then(Value::as_str).map(str::trim) else {
return "generate".to_string();
};
if value.eq_ignore_ascii_case("edit") {
"edit".to_string()
} else {
"generate".to_string()
}
}
fn build_failed_sse(request: &ChatGptWebImageRequest, failure: &Value) -> String {
let failed = if failure.get("type").and_then(Value::as_str) == Some("response.failed") {
failure.clone()
} else {
let operation = match request.operation.as_str() {
"edit" => "edit",
_ => "generation",
};
json!({
"type": "response.failed",
"response": {
"status": "failed",
"model": request.model,
"error": failure.get("error").cloned().unwrap_or_else(|| json!({
"code": "chatgpt_web_image_failed",
"message": format!("ChatGPT-Web image {operation} failed")
}))
}
})
};
format!("event: response.failed\ndata: {failed}\n\ndata: [DONE]\n\n")
}
fn output_format_from_mime(mime: &str, fallback: &str) -> String {
match mime {
"image/jpeg" | "image/jpg" => "jpeg",
"image/webp" => "webp",
"image/png" => "png",
_ => fallback,
}
.to_string()
}
fn json_execution_result(
plan: &ExecutionPlan,
status_code: u16,
body: Value,
started_at: Instant,
) -> ExecutionResult {
ExecutionResult {
request_id: plan.request_id.clone(),
candidate_id: plan.candidate_id.clone(),
status_code,
headers: BTreeMap::from([("content-type".to_string(), "application/json".to_string())]),
response_observation: None,
body: Some(ResponseBody {
json_body: Some(body),
body_bytes_b64: None,
}),
telemetry: Some(telemetry(started_at, 0)),
error: None,
}
}
fn chatgpt_web_http_error_execution_result(
plan: &ExecutionPlan,
started_at: Instant,
status_code: u16,
message: &str,
) -> ExecutionResult {
json_execution_result(
plan,
status_code,
json!({
"error": {
"type": "upstream_error",
"code": "chatgpt_web_image_execution_unavailable",
"message": message
}
}),
started_at,
)
}
fn bytes_execution_result(
plan: &ExecutionPlan,
status_code: u16,
headers: BTreeMap<String, String>,
body: Vec<u8>,
started_at: Instant,
) -> Result<ExecutionResult, ExecutionRuntimeTransportError> {
let envelope_limit = chatgpt_web_image_sse_envelope_limit_bytes();
if body.len() > envelope_limit {
return Err(ExecutionRuntimeTransportError::BodyTooLarge {
limit_bytes: envelope_limit,
});
}
let body_len = body.len() as u64;
Ok(ExecutionResult {
request_id: plan.request_id.clone(),
candidate_id: plan.candidate_id.clone(),
status_code,
headers,
response_observation: None,
body: Some(ResponseBody {
json_body: None,
body_bytes_b64: Some(base64::engine::general_purpose::STANDARD.encode(body)),
}),
telemetry: Some(telemetry(started_at, body_len)),
error: None,
})
}
fn execution_result_frame_stream(
plan: &ExecutionPlan,
result: &ExecutionResult,
report_context: Option<&Value>,
) -> Result<BoxStream<'static, Result<Bytes, IoError>>, ExecutionRuntimeTransportError> {
// The synthetic ChatGPT-Web SSE body embeds an image as base64, so its
// envelope is larger than the decoded image/body limit. Use the bounded
// envelope budget here instead of rejecting valid images near 64 MiB.
let body =
execution_result_bytes_with_limit(result, chatgpt_web_image_sse_envelope_limit_bytes())?;
let terminal_summary = chatgpt_web_stream_terminal_summary(plan, result, report_context, &body);
let mut frames = vec![
StreamFrame {
frame_type: StreamFrameType::Headers,
payload: StreamFramePayload::Headers {
status_code: result.status_code,
headers: result.headers.clone(),
response_observation: result.response_observation.clone(),
},
},
StreamFrame {
frame_type: StreamFrameType::Telemetry,
payload: StreamFramePayload::Telemetry {
telemetry: ExecutionTelemetry {
ttfb_ms: result.telemetry.as_ref().and_then(|value| value.ttfb_ms),
elapsed_ms: result.telemetry.as_ref().and_then(|value| value.elapsed_ms),
upstream_bytes: Some(0),
},
},
},
];
for chunk in body.chunks(CHATGPT_WEB_IMAGE_STREAM_CHUNK_BYTES) {
frames.push(StreamFrame {
frame_type: StreamFrameType::Data,
payload: StreamFramePayload::Data {
chunk_b64: Some(base64::engine::general_purpose::STANDARD.encode(chunk)),
text: None,
},
});
}
frames.push(StreamFrame {
frame_type: StreamFrameType::Telemetry,
payload: StreamFramePayload::Telemetry {
telemetry: result.telemetry.clone().unwrap_or(ExecutionTelemetry {
ttfb_ms: None,
elapsed_ms: None,
upstream_bytes: None,
}),
},
});
frames.push(StreamFrame::eof_with_summary(terminal_summary));
Ok(stream::iter(
frames
.into_iter()
.map(|frame| encode_stream_frame_ndjson(&frame)),
)
.boxed())
}
fn chatgpt_web_stream_terminal_summary(
plan: &ExecutionPlan,
result: &ExecutionResult,
report_context: Option<&Value>,
body: &[u8],
) -> Option<ExecutionStreamTerminalSummary> {
if !(200..300).contains(&result.status_code) || body.is_empty() {
return None;
}
let observer_context = chatgpt_web_stream_observer_context(plan, report_context);
let mut observer = StreamingStandardTerminalObserver::default();
let mut line_start = 0usize;
for (index, byte) in body.iter().enumerate() {
if *byte != b'\n' {
continue;
}
observer
.push_line(&observer_context, body[line_start..=index].to_vec())
.ok()?;
line_start = index.saturating_add(1);
}
if line_start < body.len() {
observer
.push_line(&observer_context, body[line_start..].to_vec())
.ok()?;
}
observer.finish(&observer_context).ok().flatten()
}
fn chatgpt_web_stream_observer_context(
plan: &ExecutionPlan,
report_context: Option<&Value>,
) -> Value {
let mut context = report_context
.cloned()
.filter(Value::is_object)
.unwrap_or_else(|| json!({}));
let object = context
.as_object_mut()
.expect("observer context should be an object");
object
.entry("provider_api_format".to_string())
.or_insert_with(|| Value::String(plan.provider_api_format.clone()));
object
.entry("client_api_format".to_string())
.or_insert_with(|| Value::String(plan.client_api_format.clone()));
object
.entry("model".to_string())
.or_insert_with(|| Value::String(plan.model_name.clone().unwrap_or_default()));
if !object.contains_key("image_request") {
if let Some(image_request) = chatgpt_web_image_request_context(plan) {
object.insert("image_request".to_string(), image_request);
}
}
context
}
fn chatgpt_web_image_request_context(plan: &ExecutionPlan) -> Option<Value> {
let body = plan.body.json_body.as_ref()?.as_object()?;
let mut image_request = Map::new();
image_request.insert(
"operation".to_string(),
Value::String(chatgpt_web_image_operation(body.get("operation"))),
);
for key in [
"model",
"size",
"quality",
"ratio",
"output_format",
"partial_images",
] {
if let Some(value) = body.get(key).and_then(Value::as_str).map(str::trim) {
if !value.is_empty() {
image_request.insert(key.to_string(), Value::String(value.to_string()));
}
continue;
}
if let Some(value) = body.get(key).and_then(Value::as_u64) {
image_request.insert(key.to_string(), Value::Number(value.into()));
}
}
Some(Value::Object(image_request))
}
fn telemetry(started_at: Instant, upstream_bytes: u64) -> ExecutionTelemetry {
let elapsed_ms = started_at.elapsed().as_millis() as u64;
ExecutionTelemetry {
ttfb_ms: Some(elapsed_ms),
elapsed_ms: Some(elapsed_ms),
upstream_bytes: Some(upstream_bytes),
}
}
fn execution_result_json(
result: &ExecutionResult,
) -> Result<Value, ExecutionRuntimeTransportError> {
if let Some(json_body) = result
.body
.as_ref()
.and_then(|body| body.json_body.as_ref())
{
return Ok(json_body.clone());
}
let bytes = execution_result_bytes(result)?;
serde_json::from_slice(&bytes).map_err(ExecutionRuntimeTransportError::InvalidJson)
}
fn execution_result_bytes(
result: &ExecutionResult,
) -> Result<Vec<u8>, ExecutionRuntimeTransportError> {
execution_result_bytes_with_limit(result, crate::headers::max_internal_buffered_body_bytes())
}
fn execution_result_bytes_with_limit(
result: &ExecutionResult,
body_limit: usize,
) -> Result<Vec<u8>, ExecutionRuntimeTransportError> {
let Some(body) = result.body.as_ref() else {
return Ok(Vec::new());
};
if let Some(json_body) = body.json_body.as_ref() {
return serialize_json_body_with_limit(json_body, body_limit);
}
body.body_bytes_b64
.as_deref()
.map(|value| decode_base64_body_with_limit(value, body_limit))
.unwrap_or_else(|| Ok(Vec::new()))
}
fn execution_result_body_bytes_lossy(result: &ExecutionResult) -> Vec<u8> {
execution_result_bytes(result).unwrap_or_default()
}
pub(super) fn chatgpt_web_image_sse_envelope_limit_bytes() -> usize {
let image_limit = crate::headers::max_internal_buffered_body_bytes();
maximum_base64_len_for_decoded_limit(image_limit)
.saturating_add(CHATGPT_WEB_IMAGE_SSE_WRAPPER_OVERHEAD_BYTES)
.min(CHATGPT_WEB_IMAGE_SSE_HARD_MAX_BYTES)
}
fn chatgpt_web_image_raw_payload_limit_bytes() -> usize {
let configured_limit = crate::headers::max_internal_buffered_body_bytes();
let envelope_limit = chatgpt_web_image_sse_envelope_limit_bytes();
let available_for_base64 =
envelope_limit.saturating_sub(CHATGPT_WEB_IMAGE_SSE_WRAPPER_OVERHEAD_BYTES);
// Standard base64 expands three bytes into four. Use the floor of the
// inverse expansion so a raw image can always be represented by the
// synthetic SSE envelope without first allocating an over-sized body.
let representable_raw_limit = available_for_base64
.saturating_div(4)
.saturating_mul(3)
.max(1);
configured_limit.min(representable_raw_limit)
}
fn ensure_success(
result: &ExecutionResult,
stage: &str,
) -> Result<(), ExecutionRuntimeTransportError> {
if (200..300).contains(&result.status_code) {
return Ok(());
}
Err(ExecutionRuntimeTransportError::UpstreamHttpStatus {
status_code: result.status_code,
message: chatgpt_web_stage_http_error_message(stage, result.status_code),
})
}
fn chatgpt_web_stage_http_error_message(stage: &str, status_code: u16) -> String {
format!("{stage} returned HTTP {status_code}")
}
fn chatgpt_web_base_url_from_plan(plan: &ExecutionPlan) -> String {
let Ok(url) = url::Url::parse(&plan.url) else {
return CHATGPT_WEB_DEFAULT_BASE_URL.to_string();
};
let Some(host) = url.host_str() else {
return CHATGPT_WEB_DEFAULT_BASE_URL.to_string();
};
let port = url
.port()
.map(|port| format!(":{port}"))
.unwrap_or_default();
format!("{}://{}{}", url.scheme(), host, port)
}
fn bearer_token_from_headers(headers: &BTreeMap<String, String>) -> Option<String> {
headers
.iter()
.find(|(name, _)| name.eq_ignore_ascii_case("authorization"))
.and_then(|(_, value)| {
value
.trim()
.strip_prefix("Bearer ")
.or_else(|| value.trim().strip_prefix("bearer "))
.map(str::trim)
})
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn build_legacy_requirements_token(user_agent: &str) -> String {
let seed = format!("0.{}", Uuid::new_v4().simple());
let (answer, _) = pow_generate(seed.as_str(), "0fffff", pow_config(user_agent));
format!("gAAAAAC{answer}")
}
fn build_proof_token(seed: &str, difficulty: &str, user_agent: &str) -> String {
let (answer, solved) = pow_generate(seed.trim(), difficulty.trim(), pow_config(user_agent));
if solved {
format!("gAAAAAB{answer}")
} else {
format!(
"gAAAAAB{}",
base64::engine::general_purpose::STANDARD.encode(format!("\"{}\"", seed.trim()))
)
}
}
fn pow_config(user_agent: &str) -> Vec<Value> {
let est = FixedOffset::west_opt(5 * 3600).expect("fixed EST offset should be valid");
let now = Utc::now();
let now_est = now.with_timezone(&est);
let timestamp_ms = now.timestamp_millis() as f64;
vec![
json!(3000),
json!(format!(
"{} GMT-0500 (Eastern Standard Time)",
now_est.format("%a %b %d %Y %H:%M:%S")
)),
json!(4_294_705_152_u64),
json!(0),
json!(user_agent),
json!("https://chatgpt.com/backend-api/sentinel/sdk.js"),
json!(""),
json!("en-US"),
json!("en-US,es-US,en,es"),
json!(0),
json!("webdriver≭false"),
json!("location"),
json!("window"),
json!(timestamp_ms),
json!(Uuid::new_v4().to_string()),
json!(""),
json!(16),
json!(timestamp_ms),
]
}
fn pow_generate(seed: &str, difficulty: &str, config: Vec<Value>) -> (String, bool) {
let Some(diff_bytes) = hex_to_bytes(difficulty) else {
return (encode_pow_seed(seed), false);
};
// `sha3_512` yields exactly 64 bytes. Difficulty comes from the
// upstream sentinel response, so reject an overlong value before the
// comparison below could slice the digest out of bounds.
if diff_bytes.is_empty() || diff_bytes.len() > 64 {
return (encode_pow_seed(seed), false);
}
let static1 = serde_json::to_string(&config[..3]).unwrap_or_else(|_| "[]".to_string());
let static1 = format!("{},", static1.trim_end_matches(']'));
let static2 = serde_json::to_string(&config[4..9]).unwrap_or_else(|_| "[]".to_string());
let static2 = format!(
",{},",
static2.trim_start_matches('[').trim_end_matches(']')
);
let static3 = serde_json::to_string(&config[10..]).unwrap_or_else(|_| "[]".to_string());
let static3 = format!(",{}", static3.trim_start_matches('['));
let seed_bytes = seed.as_bytes();
for i in 0..500_000_u64 {
let final_config = format!("{static1}{i}{static2}{}{static3}", i >> 1);
let encoded = base64::engine::general_purpose::STANDARD.encode(final_config.as_bytes());
let mut candidate = Vec::with_capacity(seed_bytes.len() + encoded.len());
candidate.extend_from_slice(seed_bytes);
candidate.extend_from_slice(encoded.as_bytes());
let digest = sha3_512(candidate.as_slice());
if digest[..diff_bytes.len()] <= diff_bytes[..] {
return (encoded, true);
}
}
(encode_pow_seed(seed), false)
}
fn encode_pow_seed(seed: &str) -> String {
base64::engine::general_purpose::STANDARD.encode(format!("\"{}\"", seed.trim()))
}
fn hex_to_bytes(value: &str) -> Option<Vec<u8>> {
// Avoid copying/allocating an unbounded upstream difficulty string. The
// proof comparison cannot consume more than the 64-byte SHA-3 digest.
let trimmed = value.trim();
if trimmed.len() > 128 {
return None;
}
let mut hex = trimmed.to_string();
if hex.len() % 2 == 1 {
hex.insert(0, '0');
}
let mut out = Vec::with_capacity(hex.len() / 2);
let bytes = hex.as_bytes();
for chunk in bytes.chunks(2) {
let high = hex_nibble(chunk[0])?;
let low = hex_nibble(chunk[1])?;
out.push((high << 4) | low);
}
Some(out)
}
fn hex_nibble(value: u8) -> Option<u8> {
match value {
b'0'..=b'9' => Some(value - b'0'),
b'a'..=b'f' => Some(value - b'a' + 10),
b'A'..=b'F' => Some(value - b'A' + 10),
_ => None,
}
}
fn sha3_512(input: &[u8]) -> [u8; 64] {
const RATE: usize = 72;
let mut state = [0_u64; 25];
let mut offset = 0;
while offset + RATE <= input.len() {
absorb_sha3_block(&mut state, &input[offset..offset + RATE]);
keccak_f1600(&mut state);
offset += RATE;
}
let mut block = [0_u8; RATE];
let remaining = &input[offset..];
block[..remaining.len()].copy_from_slice(remaining);
block[remaining.len()] ^= 0x06;
block[RATE - 1] ^= 0x80;
absorb_sha3_block(&mut state, &block);
keccak_f1600(&mut state);
let mut out = [0_u8; 64];
for (lane, chunk) in state.iter().zip(out.chunks_mut(8)) {
chunk.copy_from_slice(&lane.to_le_bytes());
}
out
}
fn absorb_sha3_block(state: &mut [u64; 25], block: &[u8]) {
for (index, chunk) in block.chunks_exact(8).enumerate() {
state[index] ^= u64::from_le_bytes([
chunk[0], chunk[1], chunk[2], chunk[3], chunk[4], chunk[5], chunk[6], chunk[7],
]);
}
}
fn keccak_f1600(state: &mut [u64; 25]) {
const ROUND_CONSTANTS: [u64; 24] = [
0x0000_0000_0000_0001,
0x0000_0000_0000_8082,
0x8000_0000_0000_808a,
0x8000_0000_8000_8000,
0x0000_0000_0000_808b,
0x0000_0000_8000_0001,
0x8000_0000_8000_8081,
0x8000_0000_0000_8009,
0x0000_0000_0000_008a,
0x0000_0000_0000_0088,
0x0000_0000_8000_8009,
0x0000_0000_8000_000a,
0x0000_0000_8000_808b,
0x8000_0000_0000_008b,
0x8000_0000_0000_8089,
0x8000_0000_0000_8003,
0x8000_0000_0000_8002,
0x8000_0000_0000_0080,
0x0000_0000_0000_800a,
0x8000_0000_8000_000a,
0x8000_0000_8000_8081,
0x8000_0000_0000_8080,
0x0000_0000_8000_0001,
0x8000_0000_8000_8008,
];
const RHO: [u32; 25] = [
0, 1, 62, 28, 27, 36, 44, 6, 55, 20, 3, 10, 43, 25, 39, 41, 45, 15, 21, 8, 18, 2, 61, 56,
14,
];
for round_constant in ROUND_CONSTANTS {
let mut c = [0_u64; 5];
for x in 0..5 {
c[x] = state[x] ^ state[x + 5] ^ state[x + 10] ^ state[x + 15] ^ state[x + 20];
}
for x in 0..5 {
let d = c[(x + 4) % 5] ^ c[(x + 1) % 5].rotate_left(1);
for y in 0..5 {
state[x + 5 * y] ^= d;
}
}
let mut b = [0_u64; 25];
for x in 0..5 {
for y in 0..5 {
b[y + 5 * ((2 * x + 3 * y) % 5)] = state[x + 5 * y].rotate_left(RHO[x + 5 * y]);
}
}
for y in 0..5 {
for x in 0..5 {
state[x + 5 * y] =
b[x + 5 * y] ^ ((!b[(x + 1) % 5 + 5 * y]) & b[(x + 2) % 5 + 5 * y]);
}
}
state[0] ^= round_constant;
}
}
fn parse_data_url(value: &str) -> Option<DownloadedImage> {
parse_data_url_with_limit(value, chatgpt_web_image_raw_payload_limit_bytes())
}
fn parse_data_url_with_limit(value: &str, decoded_limit: usize) -> Option<DownloadedImage> {
let (header, data) = value.trim().split_once(',')?;
if header.is_empty()
|| data.is_empty()
|| header
.bytes()
.any(|byte| byte.is_ascii_whitespace() || byte.is_ascii_control())
|| data
.bytes()
.any(|byte| byte.is_ascii_whitespace() || byte.is_ascii_control())
{
return None;
}
// Only pass image formats that the OpenAI image surface can represent safely.
// In particular, accepting arbitrary `image/*` values would allow SVG/XML
// payloads to cross a JSON image boundary and be interpreted as active markup.
let (scheme, metadata) = header.split_once(':')?;
if !scheme.eq_ignore_ascii_case("data") {
return None;
}
let (mime, encoding) = metadata.rsplit_once(';')?;
if !encoding.eq_ignore_ascii_case("base64") || mime.contains(';') {
return None;
}
let mime = if mime.eq_ignore_ascii_case("image/png") {
"image/png"
} else if mime.eq_ignore_ascii_case("image/jpeg") || mime.eq_ignore_ascii_case("image/jpg") {
"image/jpeg"
} else if mime.eq_ignore_ascii_case("image/webp") {
"image/webp"
} else {
return None;
};
// Check the encoded length before invoking the decoder. The base64 engine
// allocates from the input length, so a decoded-size check performed after
// decoding would still leave an allocation DoS. Keep the operator-configured
// 64 MiB default so valid large image responses remain supported.
let bytes = decode_base64_body_with_limit(data, decoded_limit).ok()?;
let detected_mime = validate_web_image_payload(&bytes, Some(mime)).ok()?;
let (width, height) = image_dimensions(&bytes);
Some(DownloadedImage {
// `decode_base64_body_with_limit` has already validated the canonical
// alphabet and padding. Preserve the source text to avoid a second
// 64 MiB-scale allocation when handling large images.
b64_json: data.to_string(),
mime: detected_mime.to_string(),
width,
height,
})
}
fn image_dimensions(bytes: &[u8]) -> (Option<u32>, Option<u32>) {
if bytes.starts_with(b"\x89PNG\r\n\x1a\n") && bytes.len() >= 24 {
let width = u32::from_be_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]);
let height = u32::from_be_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]);
if width == 0
|| height == 0
|| width > CHATGPT_WEB_IMAGE_MAX_DIMENSION
|| height > CHATGPT_WEB_IMAGE_MAX_DIMENSION
{
return (None, None);
}
return (Some(width), Some(height));
}
if bytes.starts_with(&[0xff, 0xd8]) {
let mut cursor = 2usize;
while cursor + 9 < bytes.len() {
if bytes[cursor] != 0xff {
cursor += 1;
continue;
}
let marker = bytes[cursor + 1];
let segment_len = u16::from_be_bytes([bytes[cursor + 2], bytes[cursor + 3]]) as usize;
if matches!(
marker,
0xc0 | 0xc1
| 0xc2
| 0xc3
| 0xc5
| 0xc6
| 0xc7
| 0xc9
| 0xca
| 0xcb
| 0xcd
| 0xce
| 0xcf
) && cursor + 8 < bytes.len()
{
let height = u16::from_be_bytes([bytes[cursor + 5], bytes[cursor + 6]]) as u32;
let width = u16::from_be_bytes([bytes[cursor + 7], bytes[cursor + 8]]) as u32;
if width == 0
|| height == 0
|| width > CHATGPT_WEB_IMAGE_MAX_DIMENSION
|| height > CHATGPT_WEB_IMAGE_MAX_DIMENSION
{
return (None, None);
}
return (Some(width), Some(height));
}
if segment_len < 2 {
break;
}
cursor = cursor.saturating_add(2 + segment_len);
}
}
(None, None)
}
fn is_web_file_id(value: &str) -> bool {
let value = value.trim();
(value.starts_with("file-") || value.starts_with("file_"))
&& value.len() >= 10
&& web_opaque_id_is_safe(value)
}
fn web_opaque_id_is_safe(value: &str) -> bool {
!value.is_empty()
&& value.len() <= CHATGPT_WEB_OPAQUE_ID_MAX_BYTES
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
}
fn validated_web_opaque_id<'a>(
value: &'a str,
field: &str,
) -> Result<&'a str, ExecutionRuntimeTransportError> {
let value = value.trim();
if !web_opaque_id_is_safe(value) {
return Err(ExecutionRuntimeTransportError::UpstreamRequest(format!(
"ChatGPT-Web response contains an invalid {field}"
)));
}
Ok(value)
}
fn validated_web_file_id(value: &str) -> Result<&str, ExecutionRuntimeTransportError> {
let value = value.trim();
if !is_web_file_id(value) {
return Err(ExecutionRuntimeTransportError::UpstreamRequest(
"ChatGPT-Web response contains an invalid file ID".to_string(),
));
}
Ok(value)
}
fn web_dns_host_is_valid(host: &str) -> bool {
let host = host.strip_suffix('.').unwrap_or(host);
!host.is_empty()
&& !host.ends_with('.')
&& host.len() <= 253
&& host.split('.').all(|label| {
!label.is_empty()
&& label.len() <= 63
&& label
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
&& label
.as_bytes()
.first()
.is_some_and(u8::is_ascii_alphanumeric)
&& label
.as_bytes()
.last()
.is_some_and(u8::is_ascii_alphanumeric)
})
}
fn web_host_is_domain_or_subdomain(host: &str, domain: &str) -> bool {
let host = host.strip_suffix('.').unwrap_or(host);
if !web_dns_host_is_valid(host) {
return false;
}
if host.eq_ignore_ascii_case(domain) {
return true;
}
host.len() > domain.len()
&& host.as_bytes()[host.len() - domain.len() - 1] == b'.'
&& host[host.len() - domain.len()..].eq_ignore_ascii_case(domain)
}
fn web_host_is_strict_subdomain(host: &str, domain: &str) -> bool {
web_host_is_domain_or_subdomain(host, domain)
&& !host.trim_end_matches('.').eq_ignore_ascii_case(domain)
}
fn is_generated_web_asset_url(raw_url: &str) -> bool {
let Ok(url) = url::Url::parse(raw_url.trim()) else {
return false;
};
if validate_web_image_http_url(&url).is_err() {
return false;
}
let Some(host) = url.host_str() else {
return false;
};
let path = url.path().to_ascii_lowercase();
if web_host_is_domain_or_subdomain(host, "openaiassets.blob.core.windows.net") {
return false;
}
if path.contains("/$web/chatgpt/") {
return false;
}
web_host_is_domain_or_subdomain(host, "files.oaiusercontent.com")
|| web_host_is_domain_or_subdomain(host, "oaidalleapiprodscus.blob.core.windows.net")
|| (web_host_is_strict_subdomain(host, "blob.core.windows.net") && !path.contains("/$web/"))
}
fn is_authenticated_web_download_url(base: &url::Url, target: &url::Url) -> bool {
target.path().starts_with("/backend-api/")
&& web_download_url_is_same_origin(base, target)
&& target.username().is_empty()
&& target.password().is_none()
}
fn web_download_url_is_same_origin(base: &url::Url, target: &url::Url) -> bool {
target.scheme().eq_ignore_ascii_case(base.scheme())
&& target
.host_str()
.zip(base.host_str())
.is_some_and(|(target, base)| target.eq_ignore_ascii_case(base))
&& target.port_or_known_default() == base.port_or_known_default()
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
use aether_data_contracts::repository::provider_catalog::{
ProviderCatalogReadRepository, StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
StoredProviderCatalogProvider,
};
use axum::body::Body;
use axum::extract::Request;
use axum::routing::any;
use axum::Router;
use futures_util::StreamExt as _;
use http::{Method, StatusCode};
use crate::data::GatewayDataState;
fn sample_plan(base_url: &str, body: Value, stream: bool) -> ExecutionPlan {
ExecutionPlan {
request_id: "req-chatgpt-web-image-test".to_string(),
candidate_id: Some("cand-chatgpt-web-image-test".to_string()),
provider_name: Some("ChatGPT Web".to_string()),
provider_id: "provider-chatgpt-web-image-test".to_string(),
endpoint_id: "endpoint-chatgpt-web-image-test".to_string(),
key_id: "key-chatgpt-web-image-test".to_string(),
method: "POST".to_string(),
url: format!("{base_url}/__aether/chatgpt-web-image"),
headers: BTreeMap::from([
(CHATGPT_WEB_INTERNAL_HEADER.to_string(), "1".to_string()),
(
"authorization".to_string(),
"Bearer test-access-token".to_string(),
),
]),
content_type: Some("application/json".to_string()),
content_encoding: None,
body: RequestBody::from_json(body),
stream,
client_api_format: "openai:image".to_string(),
provider_api_format: "openai:image".to_string(),
model_name: Some("gpt-image-2".to_string()),
proxy: None,
transport_profile: None,
timeouts: None,
}
}
fn sample_provider_catalog_provider() -> StoredProviderCatalogProvider {
StoredProviderCatalogProvider::new(
"provider-chatgpt-web-image-test".to_string(),
"ChatGPT Web".to_string(),
Some(CHATGPT_WEB_DEFAULT_BASE_URL.to_string()),
"chatgpt_web".to_string(),
)
.expect("provider should build")
}
fn sample_provider_catalog_endpoint(base_url: &str) -> StoredProviderCatalogEndpoint {
StoredProviderCatalogEndpoint::new(
"endpoint-chatgpt-web-image-test".to_string(),
"provider-chatgpt-web-image-test".to_string(),
"openai:image".to_string(),
Some("openai".to_string()),
Some("image".to_string()),
true,
)
.expect("endpoint should build")
.with_transport_fields(
base_url.to_string(),
None,
None,
None,
None,
None,
None,
None,
)
.expect("endpoint transport fields should build")
}
fn sample_provider_catalog_key(upstream_metadata: Value) -> StoredProviderCatalogKey {
let mut key = StoredProviderCatalogKey::new(
"key-chatgpt-web-image-test".to_string(),
"provider-chatgpt-web-image-test".to_string(),
"ChatGPT Web test key".to_string(),
"oauth".to_string(),
None,
true,
)
.expect("key should build")
.with_transport_fields(
Some(json!(["openai:image"])),
None,
None,
None,
None,
None,
None,
None,
None,
)
.expect("key transport fields should build");
key.upstream_metadata = Some(upstream_metadata);
key
}
fn state_with_chatgpt_web_key(
base_url: &str,
upstream_metadata: Value,
) -> (AppState, Arc<InMemoryProviderCatalogReadRepository>) {
let repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider_catalog_provider()],
vec![sample_provider_catalog_endpoint(base_url)],
vec![sample_provider_catalog_key(upstream_metadata)],
));
let state = crate::AppState::new()
.expect("state should build")
.with_data_state_for_tests(
GatewayDataState::with_provider_catalog_repository_for_tests(Arc::clone(
&repository,
)),
);
(state, repository)
}
async fn reloaded_chatgpt_web_metadata(
repository: &InMemoryProviderCatalogReadRepository,
) -> Map<String, Value> {
repository
.list_keys_by_ids(&["key-chatgpt-web-image-test".to_string()])
.await
.expect("key reload should succeed")
.into_iter()
.next()
.expect("key should exist")
.upstream_metadata
.and_then(|value| value.get("chatgpt_web").cloned())
.and_then(|value| value.as_object().cloned())
.expect("chatgpt_web metadata should exist")
}
fn completed_response_from_sse(sse: &str) -> Value {
sse.lines()
.find_map(|line| {
let payload = line.strip_prefix("data: ")?;
let event = serde_json::from_str::<Value>(payload).ok()?;
(event.get("type").and_then(Value::as_str) == Some("response.completed"))
.then_some(event)
})
.and_then(|event| event.get("response").cloned())
.expect("completed response should be present")
}
#[test]
fn gpt_image2_output_token_estimator_matches_pricing_calculator_examples() {
assert_eq!(gpt_image2_output_tokens(1024, 1024, "low"), 196);
assert_eq!(gpt_image2_output_tokens(1024, 1024, "medium"), 1756);
assert_eq!(gpt_image2_output_tokens(1536, 1024, "medium"), 1372);
assert_eq!(gpt_image2_output_tokens(1024, 1536, "medium"), 1372);
assert_eq!(gpt_image2_output_tokens(1024, 1024, "high"), 7024);
}
#[test]
fn chatgpt_web_http_status_error_omits_upstream_body() {
let result = ExecutionResult {
request_id: "req-chatgpt-web-image-test".to_string(),
candidate_id: None,
status_code: 502,
headers: BTreeMap::new(),
response_observation: None,
body: Some(ResponseBody {
json_body: Some(json!({"error": "Bearer secret-chatgpt-web-body"})),
body_bytes_b64: None,
}),
telemetry: None,
error: None,
};
let error = ensure_success(&result, "ChatGPT-Web bootstrap")
.expect_err("non-success result should be rejected");
let message = error.to_string();
assert!(matches!(
error,
ExecutionRuntimeTransportError::UpstreamHttpStatus {
status_code: 502,
..
}
));
assert_eq!(message, "ChatGPT-Web bootstrap returned HTTP 502");
assert!(!message.contains("secret-chatgpt-web-body"));
}
#[test]
fn chatgpt_web_success_sse_includes_estimated_image_usage() {
let request = ChatGptWebImageRequest {
operation: "generate".to_string(),
model: "gpt-image-2".to_string(),
web_model: "gpt-5-5-thinking".to_string(),
prompt: "draw a test image".to_string(),
size: "1024x1024".to_string(),
ratio: "1:1".to_string(),
output_format: "png".to_string(),
quality: Some("low".to_string()),
partial_images: 0,
images: Vec::new(),
};
let image = DownloadedImage {
b64_json: "aGVsbG8=".repeat(128),
mime: "image/png".to_string(),
width: Some(1024),
height: Some(1024),
};
let body = build_success_sse(
&request,
&image,
Some(&json!({
"image_request": {
"size": "1024x1024",
"quality": "low"
}
})),
);
let completed = completed_response_from_sse(body.as_str());
let input_tokens = estimate_text_tokens("draw a test image");
let output_tokens = 196;
assert_eq!(completed["usage"]["input_tokens"], json!(input_tokens));
assert_eq!(completed["usage"]["output_tokens"], json!(output_tokens));
assert_eq!(
completed["tool_usage"]["image_gen"]["output_tokens"],
json!(output_tokens)
);
assert_eq!(
completed["tool_usage"]["image_gen"]["input_tokens_details"]["text_tokens"],
json!(input_tokens)
);
assert_eq!(
completed["tool_usage"]["image_gen"]["output_tokens_details"]["image_tokens"],
json!(output_tokens)
);
assert_eq!(
completed["usage"]["total_tokens"],
json!(input_tokens.saturating_add(output_tokens))
);
}
#[test]
fn chatgpt_web_success_sse_uses_image_dimensions_not_output_text() {
let request = ChatGptWebImageRequest {
operation: "generate".to_string(),
model: "gpt-image-2".to_string(),
web_model: "gpt-5-5-thinking".to_string(),
prompt: "draw a test image".to_string(),
size: "1024x1024".to_string(),
ratio: "1:1".to_string(),
output_format: "png".to_string(),
quality: Some("low".to_string()),
partial_images: 0,
images: Vec::new(),
};
let image = DownloadedImage {
b64_json: "iVBORw0KGgoAAAANSUhEUgAA".repeat(64),
mime: "image/png".to_string(),
width: Some(1402),
height: Some(1122),
};
let body = build_success_sse(
&request,
&image,
Some(&json!({
"image_request": {
"size": "1024x1024",
"quality": "low"
}
})),
);
let completed = completed_response_from_sse(body.as_str());
assert_eq!(
completed["usage"]["output_tokens"],
json!(gpt_image2_output_tokens(1402, 1122, "low"))
);
}
#[test]
fn chatgpt_web_image_subrequests_default_to_browser_wreq_transport() {
let plan = sample_plan(
CHATGPT_WEB_DEFAULT_BASE_URL,
json!({"prompt": "draw a small test image"}),
false,
);
let profile = chatgpt_web_image_transport_profile(&plan).expect("transport profile");
assert_eq!(profile.backend, TRANSPORT_BACKEND_BROWSER_WREQ);
assert_eq!(profile.profile_id, CHATGPT_WEB_BROWSER_PROFILE);
assert_eq!(profile.http_mode, TRANSPORT_HTTP_MODE_AUTO);
assert_eq!(profile.pool_scope, TRANSPORT_POOL_SCOPE_KEY);
assert_eq!(
profile
.extra
.as_ref()
.and_then(|value| value.get("source"))
.and_then(Value::as_str),
Some("chatgpt_web_image_default")
);
}
#[test]
fn chatgpt_web_image_request_context_preserves_edit_operation() {
let plan = sample_plan(
CHATGPT_WEB_DEFAULT_BASE_URL,
json!({
"operation": "edit",
"model": "gpt-image-2",
"web_model": "gpt-5-5-thinking",
"prompt": "adjust this image",
"size": "512x512",
"ratio": "1:1",
"images": ["data:image/png;base64,aW1hZ2U="],
"count": 1,
"output_format": "png"
}),
true,
);
let context = chatgpt_web_stream_observer_context(&plan, None);
assert_eq!(context["image_request"]["operation"], json!("edit"));
assert_eq!(context["image_request"]["model"], json!("gpt-image-2"));
assert_eq!(context["image_request"]["size"], json!("512x512"));
assert_eq!(context["provider_api_format"], json!("openai:image"));
}
#[test]
fn chatgpt_web_image_quota_refresh_plan_uses_conversation_init() {
let plan = sample_plan(
CHATGPT_WEB_DEFAULT_BASE_URL,
json!({"prompt": "draw a small test image"}),
false,
);
let spec = build_chatgpt_web_pool_quota_request(
&plan.key_id,
CHATGPT_WEB_DEFAULT_BASE_URL,
(
"authorization".to_string(),
"Bearer test-access-token".to_string(),
),
);
let quota_plan = build_chatgpt_web_image_quota_refresh_plan(&plan, spec);
assert_eq!(quota_plan.method, "POST");
assert_eq!(
quota_plan.url,
"https://chatgpt.com/backend-api/conversation/init"
);
assert_eq!(
quota_plan.provider_api_format,
"chatgpt_web:conversation_init"
);
assert_eq!(
quota_plan.headers.get("authorization").map(String::as_str),
Some("Bearer test-access-token")
);
assert_eq!(
quota_plan
.transport_profile
.as_ref()
.map(|profile| profile.backend.as_str()),
Some(TRANSPORT_BACKEND_BROWSER_WREQ)
);
assert_eq!(
quota_plan
.timeouts
.as_ref()
.and_then(|timeouts| timeouts.total_ms),
Some(CHATGPT_WEB_QUOTA_REFRESH_TIMEOUT_MS)
);
}
#[test]
fn chatgpt_web_image_quota_request_delta_decrements_remaining_count() {
let mut metadata = Map::from_iter([
("image_quota_remaining".to_string(), json!(25.0)),
("image_quota_total".to_string(), json!(25.0)),
("image_quota_used".to_string(), json!(0.0)),
("image_quota_reset_at".to_string(), json!(2_000u64)),
]);
assert!(apply_chatgpt_web_image_quota_request_delta_to_metadata(
&mut metadata,
None,
1_000,
None,
));
assert_eq!(metadata["image_quota_remaining"], json!(24.0));
assert_eq!(metadata["image_quota_total"], json!(25.0));
assert_eq!(metadata["image_quota_used"], json!(1.0));
assert_eq!(metadata["image_quota_local_request_count"], json!(1u64));
}
#[test]
fn chatgpt_web_image_quota_request_delta_can_use_status_snapshot() {
let mut metadata = Map::new();
let status_snapshot = json!({
"quota": {
"provider_type": "chatgpt_web",
"windows": [{
"code": "image_gen",
"scope": "account",
"remaining_value": 19.0,
"limit_value": 25.0,
"used_value": 6.0,
"reset_at": 2_000u64
}]
}
});
assert!(apply_chatgpt_web_image_quota_request_delta_to_metadata(
&mut metadata,
Some(&status_snapshot),
1_000,
None,
));
assert_eq!(metadata["image_quota_remaining"], json!(18.0));
assert_eq!(metadata["image_quota_total"], json!(25.0));
assert_eq!(metadata["image_quota_used"], json!(7.0));
assert_eq!(metadata["image_quota_reset_at"], json!(2_000u64));
}
#[test]
fn chatgpt_web_image_quota_request_delta_records_unknown_quota_use() {
let mut metadata = Map::new();
assert!(apply_chatgpt_web_image_quota_request_delta_to_metadata(
&mut metadata,
None,
1_000,
None,
));
assert_eq!(metadata.get("image_quota_remaining"), None);
assert_eq!(metadata.get("image_quota_total"), None);
assert_eq!(metadata["image_quota_used"], json!(1.0));
assert_eq!(metadata["image_quota_local_request_count"], json!(1u64));
assert_eq!(metadata["updated_at"], json!(1_000u64));
}
#[test]
fn chatgpt_web_image_quota_request_delta_derives_remaining_from_limit_only() {
let mut metadata = Map::from_iter([("image_quota_total".to_string(), json!(10.0))]);
assert!(apply_chatgpt_web_image_quota_request_delta_to_metadata(
&mut metadata,
None,
1_000,
None,
));
assert_eq!(metadata["image_quota_remaining"], json!(9.0));
assert_eq!(metadata["image_quota_total"], json!(10.0));
assert_eq!(metadata["image_quota_used"], json!(1.0));
}
#[test]
fn chatgpt_web_image_quota_request_delta_ignores_legacy_free_25_limit() {
let mut metadata = Map::from_iter([
("plan_type".to_string(), json!("free")),
("image_quota_remaining".to_string(), json!(19.0)),
("image_quota_total".to_string(), json!(25.0)),
("image_quota_used".to_string(), json!(6.0)),
]);
assert!(apply_chatgpt_web_image_quota_request_delta_to_metadata(
&mut metadata,
None,
1_000,
None,
));
assert_eq!(metadata["image_quota_remaining"], json!(18.0));
assert_eq!(metadata["image_quota_total"], json!(19.0));
assert_eq!(metadata["image_quota_used"], json!(1.0));
assert_eq!(
metadata["image_quota_limit_source"],
json!("first_remaining")
);
}
#[test]
fn chatgpt_web_image_quota_request_delta_ignores_legacy_free_25_without_remaining() {
let mut metadata = Map::from_iter([
("plan_type".to_string(), json!("free")),
("image_quota_total".to_string(), json!(25.0)),
]);
assert!(apply_chatgpt_web_image_quota_request_delta_to_metadata(
&mut metadata,
None,
1_000,
None,
));
assert_eq!(metadata.get("image_quota_remaining"), None);
assert_eq!(metadata.get("image_quota_total"), None);
assert_eq!(metadata["image_quota_used"], json!(1.0));
assert_eq!(
metadata.get("image_quota_limit_source"),
None,
"legacy free default should not become a first observed limit without remaining"
);
}
#[test]
fn chatgpt_web_image_quota_request_delta_dedupes_same_candidate_start() {
let mut metadata = Map::from_iter([
("plan_type".to_string(), json!("free")),
("image_quota_remaining".to_string(), json!(25.0)),
("image_quota_total".to_string(), json!(25.0)),
("image_quota_used".to_string(), json!(0.0)),
]);
assert!(apply_chatgpt_web_image_quota_request_delta_to_metadata(
&mut metadata,
None,
1_000,
Some("request-1:candidate-1"),
));
assert!(!apply_chatgpt_web_image_quota_request_delta_to_metadata(
&mut metadata,
None,
1_001,
Some("request-1:candidate-1"),
));
assert!(apply_chatgpt_web_image_quota_request_delta_to_metadata(
&mut metadata,
None,
1_002,
Some("request-1:candidate-2"),
));
assert_eq!(metadata["image_quota_remaining"], json!(23.0));
assert_eq!(metadata["image_quota_total"], json!(25.0));
assert_eq!(metadata["image_quota_used"], json!(2.0));
assert_eq!(metadata["image_quota_local_request_count"], json!(2u64));
assert_eq!(
metadata["image_quota_last_local_request_key"],
json!("request-1:candidate-2")
);
}
async fn start_mock_chatgpt_web() -> (String, tokio::task::JoinHandle<()>) {
let app = Router::new().fallback(any(|request: Request| async move {
let path = request.uri().path().to_string();
let method = request.method().clone();
match (method, path.as_str()) {
(Method::GET, "/") => response(StatusCode::OK, "text/html", "ok"),
(Method::POST, "/backend-api/sentinel/chat-requirements") => json_response(json!({
"token": "requirements-token",
"proofofwork": {"required": false},
"arkose": {"required": false}
})),
(Method::POST, "/backend-api/f/conversation/prepare") => {
json_response(json!({"conduit_token": "conduit-token"}))
}
(Method::POST, "/backend-api/f/conversation") => response(
StatusCode::OK,
"text/event-stream",
concat!(
"data: {\"conversation_id\":\"conv-test-1\"}\n\n",
"data: {\"message\":{\"content\":{\"parts\":[\"working\"]}},\"asset\":\"file-generated-123456\"}\n\n",
"data: [DONE]\n\n"
),
),
(Method::GET, "/backend-api/files/download/file-generated-123456") => {
json_response(json!({"download_url": "/generated.png"}))
}
(Method::GET, "/generated.png") => response(
StatusCode::OK,
"image/png",
png_header_bytes(2, 3),
),
_ => response(StatusCode::NOT_FOUND, "text/plain", "not found"),
}
}));
let listener = crate::test_support::bind_loopback_listener()
.await
.expect("listener should bind");
let addr = listener.local_addr().expect("local addr should resolve");
let handle = tokio::spawn(async move {
axum::serve(listener, app)
.await
.expect("mock server should run");
});
(format!("http://{addr}"), handle)
}
async fn start_bootstrap_failing_chatgpt_web() -> (String, tokio::task::JoinHandle<()>) {
let app = Router::new().fallback(any(|_request: Request| async move {
response(
StatusCode::INTERNAL_SERVER_ERROR,
"text/plain",
"bootstrap failed",
)
}));
let listener = crate::test_support::bind_loopback_listener()
.await
.expect("listener should bind");
let addr = listener.local_addr().expect("local addr should resolve");
let handle = tokio::spawn(async move {
axum::serve(listener, app)
.await
.expect("mock server should run");
});
(format!("http://{addr}"), handle)
}
fn response(
status: StatusCode,
content_type: &'static str,
body: impl Into<Body>,
) -> http::Response<Body> {
http::Response::builder()
.status(status)
.header(http::header::CONTENT_TYPE, content_type)
.body(body.into())
.expect("response should build")
}
fn json_response(body: Value) -> http::Response<Body> {
response(
StatusCode::OK,
"application/json",
serde_json::to_vec(&body).expect("json should encode"),
)
}
fn png_header_bytes(width: u32, height: u32) -> Vec<u8> {
let mut bytes = Vec::from(&b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR"[..]);
bytes.extend_from_slice(&width.to_be_bytes());
bytes.extend_from_slice(&height.to_be_bytes());
bytes
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
#[test]
fn parse_web_image_sse_extracts_completed_output_result() {
let summary = parse_web_image_sse(
br#"data: {"type":"response.completed","response":{"output":[{"type":"image_generation_call","result":"ZmFrZS1pbWFnZQ==","output_format":"webp"}]}}
data: [DONE]
"#,
);
assert_eq!(
summary.direct_urls,
vec!["data:image/webp;base64,ZmFrZS1pbWFnZQ=="]
);
}
#[test]
fn parse_web_image_sse_extracts_partial_image_result() {
let summary = parse_web_image_sse(
br#"data: {"type":"response.image_generation_call.partial_image","partial_image_b64":"cGFydGlhbA==","output_format":"jpeg"}
data: [DONE]
"#,
);
assert_eq!(
summary.direct_urls,
vec!["data:image/jpeg;base64,cGFydGlhbA=="]
);
}
#[test]
fn parse_web_image_sse_preserves_inline_output_format() {
let jpeg_payload =
base64::engine::general_purpose::STANDARD.encode([0xff, 0xd8, 0xff, 0xd9]);
let event = format!(
"data: {{\"type\":\"response.output_item.done\",\"item\":{{\"type\":\"image_generation_call\",\"result\":\"{jpeg_payload}\",\"output_format\":\"jpeg\"}}}}\n\n"
);
let summary = parse_web_image_sse(event.as_bytes());
assert_eq!(
summary.direct_urls,
vec![format!("data:image/jpeg;base64,{jpeg_payload}")]
);
}
#[test]
fn parse_web_image_sse_preserves_response_failed_event() {
let summary = parse_web_image_sse(
br#"data: {"type":"response.failed","response":{"status":"failed","error":{"code":"rate_limit_exceeded","message":"limited"}}}
data: [DONE]
"#,
);
assert_eq!(
summary
.failure
.as_ref()
.and_then(|value| value.get("type"))
.and_then(Value::as_str),
Some("response.failed")
);
}
#[test]
fn generated_asset_filter_does_not_drop_icon_or_logo_outputs() {
for accepted in [
"https://files.oaiusercontent.com/generated/icon-logo-output.png",
"https://cdn.files.oaiusercontent.com/generated/image.png",
"https://oaidalleapiprodscus.blob.core.windows.net/generated/image.png",
"https://tenant.blob.core.windows.net/generated/image.png",
"https://files.oaiusercontent.com./generated/image.png",
] {
assert!(
is_generated_web_asset_url(accepted),
"generated image host should be accepted: {accepted}"
);
}
assert!(!is_generated_web_asset_url(
"https://openaiassets.blob.core.windows.net/$web/chatgpt/filled-plus-icon.svg"
));
for rejected in [
"https://notfiles.oaiusercontent.com/generated/image.png",
"https://files.oaiusercontent.com.attacker.invalid/generated/image.png",
"https://not-oaidalleapiprodscus.blob.core.windows.net.attacker.invalid/image.png",
"https://blob.core.windows.net/generated/image.png",
"https://openaiassets.blob.core.windows.net/generated/image.png",
"https://sub.openaiassets.blob.core.windows.net/generated/image.png",
"ftp://files.oaiusercontent.com/generated/image.png",
"https://[email protected]/generated/image.png",
] {
assert!(
!is_generated_web_asset_url(rejected),
"lookalike or static asset host should be rejected: {rejected}"
);
}
}
#[test]
fn web_opaque_ids_reject_path_and_query_injection() {
for accepted in ["conv-test_123", "file-generated-123456", "sediment_123"] {
assert!(web_opaque_id_is_safe(accepted));
}
assert!(is_web_file_id("file-generated-123456"));
for rejected in [
"../admin",
"conv/other",
"conv?inline=true",
"conv#fragment",
"conv%2fadmin",
"conv&inline=true",
"conv=value",
"conv value",
"\r\nX-Injected: true",
] {
assert!(
!web_opaque_id_is_safe(rejected),
"unsafe opaque ID should be rejected: {rejected:?}"
);
}
assert!(!web_opaque_id_is_safe(
"a".repeat(CHATGPT_WEB_OPAQUE_ID_MAX_BYTES + 1).as_str()
));
assert!(!is_web_file_id("file-generated-123456/../../admin"));
assert!(validated_web_file_id("file-generated-123456?download=1").is_err());
}
#[test]
fn web_image_value_extraction_keeps_only_safe_opaque_ids() {
let mut summary = WebImageSseSummary::default();
extract_web_image_values(
&json!({
"conversation_id": "conv-test_123",
"file": "file-generated-123456",
"sediment": "sediment://sediment_123"
}),
&mut summary,
);
assert_eq!(summary.conversation_id.as_deref(), Some("conv-test_123"));
assert_eq!(summary.file_ids, vec!["file-generated-123456"]);
assert_eq!(summary.sediment_ids, vec!["sediment_123"]);
let mut malicious = WebImageSseSummary::default();
extract_web_image_values(
&json!({
"conversation_id": "conv-test?inline=true",
"file": "file-generated-123456/../../admin",
"sediment": "sediment://sediment_123?download=1"
}),
&mut malicious,
);
assert!(malicious.conversation_id.is_none());
assert!(malicious.file_ids.is_empty());
assert!(malicious.sediment_ids.is_empty());
}
#[test]
fn chatgpt_web_image_url_validation_requires_absolute_http_without_credentials() {
assert!(parse_absolute_web_image_url("https://cdn.example/image.png").is_ok());
for rejected in [
"/relative.png",
"file:///etc/passwd",
"ftp://cdn.example/image.png",
"https://user:[email protected]/image.png",
] {
assert!(
parse_absolute_web_image_url(rejected).is_err(),
"URL should be rejected: {rejected}"
);
}
}
#[test]
fn chatgpt_web_upload_url_is_restricted_to_signed_storage_origins() {
for accepted in [
"https://files.oaiusercontent.com/upload/blob?sig=abc&se=123",
"https://cdn.files.oaiusercontent.com/upload/blob?sig=abc",
"https://oaidalleapiprodscus.blob.core.windows.net/container/blob?sig=abc",
"https://tenant.blob.core.windows.net/container/blob?sig=abc",
"https://tenant.blob.core.windows.net:443/container/blob?sig=abc",
] {
assert!(
validate_chatgpt_web_upload_url(accepted).is_ok(),
"valid storage URL should be accepted: {accepted}"
);
}
for rejected in [
"http://files.oaiusercontent.com/upload/blob?sig=abc",
"https://127.0.0.1/upload/blob?sig=abc",
"https://user:[email protected]/upload/blob?sig=abc",
"https://files.oaiusercontent.com.attacker.invalid/upload/blob?sig=abc",
"https://attacker.invalid/upload/blob?sig=abc",
"https://blob.core.windows.net/upload/blob?sig=abc",
"https://openaiassets.blob.core.windows.net/upload/blob?sig=abc",
"https://tenant.blob.core.windows.net:8443/upload/blob?sig=abc",
"https://tenant.blob.core.windows.net/upload/blob?sig=abc#fragment",
] {
assert!(
validate_chatgpt_web_upload_url(rejected).is_err(),
"unsafe storage URL should be rejected: {rejected}"
);
}
let oversized = format!(
"https://files.oaiusercontent.com/upload/blob?sig={}",
"a".repeat(CHATGPT_WEB_IMAGE_MAX_UPLOAD_URL_BYTES)
);
assert!(validate_chatgpt_web_upload_url(&oversized).is_err());
}
#[test]
fn chatgpt_web_image_request_fields_are_bounded() {
let oversized_prompt = json!({
"prompt": "x".repeat(CHATGPT_WEB_IMAGE_MAX_PROMPT_BYTES + 1)
});
assert!(ChatGptWebImageRequest::from_body(&oversized_prompt).is_err());
let oversized_images = json!({
"images": vec!["data:image/png;base64,AA=="; CHATGPT_WEB_IMAGE_MAX_INPUT_IMAGES + 1]
});
assert!(ChatGptWebImageRequest::from_body(&oversized_images).is_err());
let too_many_partial_images = json!({"partial_images": 4});
assert!(ChatGptWebImageRequest::from_body(&too_many_partial_images).is_err());
}
#[test]
fn chatgpt_web_image_summary_bounds_assets_across_merges() {
let mut summary = WebImageSseSummary::default();
for round in 0..32 {
let mut poll = WebImageSseSummary::default();
poll.add_values(
WebImageSummaryCollection::FileId,
(0..16).map(|index| format!("file-{round}-{index}")),
);
poll.add_values(
WebImageSummaryCollection::SedimentId,
(0..16).map(|index| format!("sediment-{round}-{index}")),
);
poll.add_values(
WebImageSummaryCollection::DirectUrl,
(0..16).map(|index| format!("https://files.oaiusercontent.com/{round}/{index}")),
);
merge_web_summary(&mut summary, &mut poll);
}
assert!(summary.retained_item_count() <= CHATGPT_WEB_IMAGE_SUMMARY_MAX_ITEMS);
assert!(summary.retained_value_bytes() <= chatgpt_web_image_sse_envelope_limit_bytes());
}
#[test]
fn chatgpt_web_image_data_url_is_bounded_before_formatting() {
let oversized = "A".repeat(
maximum_base64_len_for_decoded_limit(chatgpt_web_image_raw_payload_limit_bytes())
.saturating_add(1),
);
assert!(bounded_web_image_data_url("image/png", &oversized).is_none());
assert!(bounded_web_image_data_url("image/png", "AAAA").is_some());
}
#[test]
fn chatgpt_web_image_same_origin_requires_scheme_host_and_effective_port() {
let base = url::Url::parse("https://chatgpt.example").expect("base URL should parse");
for same_origin in [
"https://chatgpt.example/backend-api/files/download/file-1",
"https://CHATGPT.example:443/backend-api/files/download/file-1",
] {
let target = url::Url::parse(same_origin).expect("target URL should parse");
assert!(web_download_url_is_same_origin(&base, &target));
}
for cross_origin in [
"http://chatgpt.example/backend-api/files/download/file-1",
"https://chatgpt.example:444/backend-api/files/download/file-1",
"https://cdn.chatgpt.example/backend-api/files/download/file-1",
] {
let target = url::Url::parse(cross_origin).expect("target URL should parse");
assert!(!web_download_url_is_same_origin(&base, &target));
}
}
#[test]
fn chatgpt_web_image_authentication_is_limited_to_same_origin_backend_api_paths() {
let base = url::Url::parse("https://chatgpt.example").expect("base URL should parse");
let authenticated =
url::Url::parse("https://chatgpt.example/backend-api/files/download/file-1")
.expect("authenticated URL should parse");
assert!(is_authenticated_web_download_url(&base, &authenticated));
for unauthenticated in [
"https://chatgpt.example/generated.png",
"https://chatgpt.example/backend-api-impersonator/image.png",
"https://cdn.example/backend-api/files/download/file-1",
"http://chatgpt.example/backend-api/files/download/file-1",
"https://chatgpt.example:444/backend-api/files/download/file-1",
"https://[email protected]/backend-api/files/download/file-1",
] {
let target = url::Url::parse(unauthenticated).expect("target URL should parse");
assert!(
!is_authenticated_web_download_url(&base, &target),
"provider credentials must not be sent to {unauthenticated}"
);
}
}
#[test]
fn chatgpt_web_data_url_parser_accepts_only_bounded_supported_image_types() {
let payload = base64::engine::general_purpose::STANDARD.encode(png_header_bytes(2, 3));
let png =
parse_data_url_with_limit(format!("data:image/png;base64,{payload}").as_str(), 64)
.expect("png data URL should parse");
assert_eq!(png.mime, "image/png");
assert_eq!(png.b64_json, payload);
let jpeg_payload =
base64::engine::general_purpose::STANDARD.encode([0xff, 0xd8, 0xff, 0xd9]);
let jpeg = parse_data_url_with_limit(
format!("DATA:IMAGE/JPEG;BASE64,{jpeg_payload}").as_str(),
64,
)
.expect("jpeg data URL should parse");
assert_eq!(jpeg.mime, "image/jpeg");
for rejected in [
"data:text/html;base64,PGh0bWw+",
"data:image/svg+xml;base64,PHN2Zz4=",
"data:image/gif;base64,R0lGODlh",
"data:image/png;base64,",
"data:image/png;base64,!!!!",
"data:image/png;charset=utf-8;base64,aW1hZ2U=",
"data:image/png;base64,aW1h\nZ2U=",
"data:image/png;base64,PHN2Zz4=",
] {
assert!(
parse_data_url_with_limit(rejected, 64).is_none(),
"unsafe data URL should be rejected: {rejected}"
);
}
}
#[test]
fn chatgpt_web_data_url_parser_enforces_decoded_limit_before_allocation() {
let exact_bytes = png_header_bytes(2, 3);
let exact_payload = base64::engine::general_purpose::STANDARD.encode(&exact_bytes);
let exact = parse_data_url_with_limit(
format!("data:image/png;base64,{exact_payload}").as_str(),
exact_bytes.len(),
)
.expect("payload at the decoded limit should parse");
assert_eq!(exact.b64_json, exact_payload);
let exact_len = exact_bytes.len();
let mut over_bytes = exact_bytes.clone();
over_bytes.push(0);
let over_payload = base64::engine::general_purpose::STANDARD.encode(over_bytes);
assert!(
parse_data_url_with_limit(
format!("data:image/png;base64,{over_payload}").as_str(),
exact_len,
)
.is_none(),
"payload over the decoded limit must be rejected"
);
}
#[test]
fn chatgpt_web_image_payload_requires_supported_magic_and_matching_mime() {
let png = png_header_bytes(2, 3);
assert_eq!(
validate_web_image_payload(&png, Some("image/png; charset=binary"))
.expect("valid png should pass"),
"image/png"
);
assert_eq!(
validate_web_image_payload(&png, Some("application/octet-stream"))
.expect("octet-stream with a valid signature should pass"),
"image/png"
);
assert!(validate_web_image_payload(&png, Some("image/jpeg")).is_err());
assert!(validate_web_image_payload(&png, Some("image/svg+xml")).is_err());
assert!(validate_web_image_payload(b"<svg><script>x</script></svg>", None).is_err());
assert!(
validate_web_image_payload(b"<html>not an image</html>", Some("image/png")).is_err()
);
assert_eq!(
validate_web_image_payload(&[0xff, 0xd8, 0xff, 0xd9], Some("image/jpg"))
.expect("jpeg signature should pass"),
"image/jpeg"
);
assert_eq!(
validate_web_image_payload(b"RIFF\x04\0\0\0WEBP", None)
.expect("webp signature should pass"),
"image/webp"
);
assert!(validate_web_image_payload(&[0xff, 0xd8], Some("image/jpeg")).is_err());
}
#[test]
fn chatgpt_web_image_sse_envelope_budget_covers_base64_expansion() {
let raw_limit = crate::headers::max_internal_buffered_body_bytes();
let expected_minimum = maximum_base64_len_for_decoded_limit(raw_limit)
.saturating_add(CHATGPT_WEB_IMAGE_SSE_WRAPPER_OVERHEAD_BYTES)
.min(CHATGPT_WEB_IMAGE_SSE_HARD_MAX_BYTES);
assert!(chatgpt_web_image_sse_envelope_limit_bytes() >= expected_minimum);
assert!(chatgpt_web_image_sse_envelope_limit_bytes() >= raw_limit.min(64 * 1024 * 1024));
}
#[test]
fn chatgpt_web_execution_result_body_decode_is_bounded() {
let result = ExecutionResult {
request_id: "req-chatgpt-web-image-test".to_string(),
candidate_id: None,
status_code: 200,
headers: BTreeMap::new(),
response_observation: None,
body: Some(ResponseBody {
json_body: None,
body_bytes_b64: Some("!!!!".to_string()),
}),
telemetry: None,
error: None,
};
assert!(execution_result_bytes(&result).is_err());
assert!(execution_result_body_bytes_lossy(&result).is_empty());
}
#[tokio::test]
async fn chatgpt_web_public_image_resolution_rejects_private_ip_literals() {
for private_url in [
"http://127.0.0.1/image.png",
"http://169.254.169.254/latest/meta-data",
] {
let url = url::Url::parse(private_url).expect("private URL should parse");
let error = resolve_public_web_image_addrs(&url, Duration::from_secs(5), false)
.await
.expect_err("private address must be rejected");
assert!(
error.to_string().contains("private or reserved"),
"unexpected error for {private_url}: {error}"
);
}
let public_url =
url::Url::parse("https://8.8.8.8/image.png").expect("public URL should parse");
let (host, addresses) =
resolve_public_web_image_addrs(&public_url, Duration::from_secs(5), false)
.await
.expect("public IP literal should be accepted");
assert_eq!(host, "8.8.8.8");
assert_eq!(addresses, vec!["8.8.8.8:443".parse().unwrap()]);
}
#[test]
fn chatgpt_web_image_fake_ip_exception_is_limited_to_storage_origins() {
let storage =
url::Url::parse("https://files.oaiusercontent.com/generated/image.png?sig=test")
.expect("storage URL should parse");
let fake = vec!["198.18.75.234:443".parse().unwrap()];
assert!(validate_public_web_image_addresses(&storage, &fake, true).is_ok());
assert!(validate_public_web_image_addresses(&storage, &fake, false).is_err());
let arbitrary = url::Url::parse("https://cdn.example/generated/image.png")
.expect("arbitrary URL should parse");
assert!(validate_public_web_image_addresses(&arbitrary, &fake, true).is_err());
let mixed = vec![
"198.18.75.234:443".parse().unwrap(),
"10.0.0.1:443".parse().unwrap(),
];
assert!(validate_public_web_image_addresses(&storage, &mixed, true).is_err());
}
#[test]
fn sha3_512_matches_standard_empty_input_vector() {
assert_eq!(
hex(&sha3_512(b"")),
concat!(
"a69f73cca23a9ac5c8b567dc185a756e97c982164fe25859e0d1dcc1475c80a",
"615b2123af1f5f94c11e3e9402c3ac558f500199d95b6d3e301758586281dcd26"
)
);
}
#[test]
fn pow_generate_solves_easy_target() {
let (answer, solved) = pow_generate("seed", "ff", pow_config(CHATGPT_WEB_USER_AGENT));
assert!(solved);
assert!(!answer.is_empty());
}
#[test]
fn pow_generate_rejects_difficulty_larger_than_digest() {
let seed = "seed";
let (answer, solved) = pow_generate(
seed,
"f".repeat(130).as_str(),
pow_config(CHATGPT_WEB_USER_AGENT),
);
assert!(!solved);
assert_eq!(answer, encode_pow_seed(seed));
}
#[tokio::test]
async fn chatgpt_web_image_executor_downloads_file_id_result_as_openai_image_sse() {
let (base_url, handle) = start_mock_chatgpt_web().await;
let state = crate::AppState::new().expect("state should build");
let plan = sample_plan(
base_url.as_str(),
json!({
"operation": "generate",
"model": "gpt-image-2",
"web_model": "gpt-5-5-thinking",
"prompt": "draw a precise test image",
"size": "512x512",
"ratio": "1:1",
"size_best_effort": true,
"images": [],
"count": 1,
"output_format": "png"
}),
false,
);
let result = maybe_execute_chatgpt_web_image_sync(
&state,
&plan,
Some(&json!({"chatgpt_web_image": true})),
)
.await
.expect("executor should run")
.expect("plan should be intercepted");
assert_eq!(result.status_code, 200);
assert_eq!(
result.headers.get("content-type").map(String::as_str),
Some("text/event-stream")
);
let body = String::from_utf8(execution_result_body_bytes_lossy(&result))
.expect("sse body should be utf8");
assert!(body.contains("response.output_item.done"));
assert!(body.contains("\"type\":\"image_generation_call\""));
assert!(body.contains("\"width\":2"));
assert!(body.contains("\"height\":3"));
let expected_output_text =
base64::engine::general_purpose::STANDARD.encode(png_header_bytes(2, 3));
assert!(body.contains(&expected_output_text));
let completed = completed_response_from_sse(body.as_str());
assert_eq!(completed["usage"]["output_tokens"], json!(1756));
assert_eq!(
completed["tool_usage"]["image_gen"]["output_tokens"],
json!(1756)
);
handle.abort();
}
#[tokio::test]
async fn chatgpt_web_image_executor_decrements_quota_after_conversation_start_once() {
let (base_url, handle) = start_mock_chatgpt_web().await;
let (state, repository) = state_with_chatgpt_web_key(
base_url.as_str(),
json!({
"chatgpt_web": {
"plan_type": "free",
"image_quota_remaining": 25.0,
"image_quota_total": 25.0,
"image_quota_used": 0.0
}
}),
);
let plan = sample_plan(
base_url.as_str(),
json!({
"operation": "generate",
"model": "gpt-image-2",
"web_model": "gpt-5-5-thinking",
"prompt": "draw a precise test image",
"size": "512x512",
"ratio": "1:1",
"images": [],
"count": 1,
"output_format": "png"
}),
false,
);
let result = maybe_execute_chatgpt_web_image_sync(
&state,
&plan,
Some(&json!({"chatgpt_web_image": true})),
)
.await
.expect("executor should run")
.expect("plan should be intercepted");
assert_eq!(result.status_code, 200);
let metadata = reloaded_chatgpt_web_metadata(repository.as_ref()).await;
assert_eq!(metadata["image_quota_remaining"], json!(24.0));
assert_eq!(metadata["image_quota_used"], json!(1.0));
assert_eq!(metadata["image_quota_local_request_count"], json!(1u64));
assert_eq!(
metadata["image_quota_last_local_request_key"],
json!("req-chatgpt-web-image-test:cand-chatgpt-web-image-test")
);
handle.abort();
}
#[tokio::test]
async fn chatgpt_web_image_executor_does_not_decrement_quota_before_conversation_start() {
let (base_url, handle) = start_bootstrap_failing_chatgpt_web().await;
let (state, repository) = state_with_chatgpt_web_key(
base_url.as_str(),
json!({
"chatgpt_web": {
"plan_type": "free",
"image_quota_remaining": 25.0,
"image_quota_total": 25.0,
"image_quota_used": 0.0
}
}),
);
let plan = sample_plan(
base_url.as_str(),
json!({
"operation": "generate",
"model": "gpt-image-2",
"web_model": "gpt-5-5-thinking",
"prompt": "draw a precise test image",
"size": "512x512",
"ratio": "1:1",
"images": [],
"count": 1,
"output_format": "png"
}),
false,
);
let result = maybe_execute_chatgpt_web_image_sync(
&state,
&plan,
Some(&json!({"chatgpt_web_image": true})),
)
.await
.expect("executor should preserve the upstream HTTP response")
.expect("plan should be intercepted");
assert_eq!(result.status_code, 500);
assert_eq!(
execution_result_json(&result).expect("error response should be json")["error"]["code"],
json!("chatgpt_web_image_execution_unavailable")
);
let metadata = reloaded_chatgpt_web_metadata(repository.as_ref()).await;
assert_eq!(metadata["image_quota_remaining"], json!(25.0));
assert_eq!(metadata["image_quota_used"], json!(0.0));
assert_eq!(metadata.get("image_quota_local_request_count"), None);
assert_eq!(metadata.get("image_quota_last_local_request_key"), None);
handle.abort();
}
#[tokio::test]
async fn chatgpt_web_image_sync_propagates_network_failure_without_synthetic_503() {
let listener = crate::test_support::bind_loopback_listener()
.await
.expect("listener should bind");
let base_url = format!(
"http://{}",
listener.local_addr().expect("local addr should resolve")
);
drop(listener);
let state = crate::AppState::new().expect("state should build");
let plan = sample_plan(
base_url.as_str(),
json!({"prompt": "draw a small test image"}),
false,
);
let error = maybe_execute_chatgpt_web_image_sync(
&state,
&plan,
Some(&json!({"chatgpt_web_image": true})),
)
.await
.expect_err("connection failure should propagate to the candidate loop");
assert!(matches!(
error,
ExecutionRuntimeTransportError::UpstreamRequest(_)
));
}
#[tokio::test]
async fn chatgpt_web_image_stream_propagates_network_failure_without_synthetic_503() {
let listener = crate::test_support::bind_loopback_listener()
.await
.expect("listener should bind");
let base_url = format!(
"http://{}",
listener.local_addr().expect("local addr should resolve")
);
drop(listener);
let state = crate::AppState::new().expect("state should build");
let plan = sample_plan(
base_url.as_str(),
json!({"prompt": "draw a small test image"}),
true,
);
let error = match maybe_execute_chatgpt_web_image_stream(
&state,
&plan,
Some(&json!({"chatgpt_web_image": true})),
)
.await
{
Err(error) => error,
Ok(_) => panic!("connection failure should propagate to the candidate loop"),
};
assert!(matches!(
error,
ExecutionRuntimeTransportError::UpstreamRequest(_)
));
}
#[tokio::test]
async fn chatgpt_web_image_stream_path_wraps_success_sse_as_ndjson_frames() {
let (base_url, handle) = start_mock_chatgpt_web().await;
let state = crate::AppState::new().expect("state should build");
let plan = sample_plan(
base_url.as_str(),
json!({
"operation": "generate",
"model": "gpt-image-2",
"web_model": "gpt-5-5-thinking",
"prompt": "draw a streamed test image",
"size": "1024x1024",
"ratio": "1:1",
"images": [],
"count": 1,
"output_format": "png"
}),
true,
);
let stream = maybe_execute_chatgpt_web_image_stream(
&state,
&plan,
Some(&json!({"chatgpt_web_image": true})),
)
.await
.expect("executor should run")
.expect("plan should be intercepted");
let chunks = stream
.frame_stream
.collect::<Vec<_>>()
.await
.into_iter()
.map(|chunk| chunk.expect("frame should encode"))
.collect::<Vec<_>>();
let text = String::from_utf8(
chunks
.iter()
.flat_map(|chunk| chunk.iter().copied())
.collect::<Vec<_>>(),
)
.expect("ndjson should be utf8");
let decoded_data = text
.lines()
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
.filter_map(|frame| {
frame
.get("payload")
.and_then(|payload| payload.get("chunk_b64"))
.and_then(Value::as_str)
.and_then(|chunk| base64::engine::general_purpose::STANDARD.decode(chunk).ok())
})
.flat_map(|bytes| String::from_utf8(bytes).ok())
.collect::<String>();
assert!(text.contains("\"status_code\":200"));
assert!(decoded_data.contains("response.output_item.done"));
assert!(decoded_data.contains("\"width\":2"));
assert!(decoded_data.contains("\"height\":3"));
assert!(text.contains("\"type\":\"eof\""));
let eof_frame = text
.lines()
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
.find(|frame| frame.get("type").and_then(Value::as_str) == Some("eof"))
.expect("eof frame should exist");
assert_eq!(
eof_frame
.get("payload")
.and_then(|payload| payload.get("summary"))
.and_then(|summary| summary.get("standardized_usage"))
.and_then(|usage| usage.get("output_tokens"))
.and_then(Value::as_i64),
Some(1756)
);
assert_eq!(
eof_frame
.get("payload")
.and_then(|payload| payload.get("summary"))
.and_then(|summary| summary.get("standardized_usage"))
.and_then(|usage| usage.get("dimensions"))
.and_then(|dimensions| dimensions.get("image_count"))
.and_then(Value::as_u64),
Some(1)
);
assert_eq!(
eof_frame
.get("payload")
.and_then(|payload| payload.get("summary"))
.and_then(|summary| summary.get("standardized_usage"))
.and_then(|usage| usage.get("dimensions"))
.and_then(|dimensions| dimensions.get("image_size"))
.and_then(Value::as_str),
Some("1024x1024")
);
handle.abort();
}
#[tokio::test]
async fn chatgpt_web_image_executor_returns_embedded_resolution_error_as_400() {
let state = crate::AppState::new().expect("state should build");
let plan = sample_plan(
CHATGPT_WEB_DEFAULT_BASE_URL,
json!({
"error": {
"message": "ChatGPT-Web 不支持该分辨率",
"type": "invalid_request_error",
"code": "chatgpt_web_image_unsupported"
}
}),
false,
);
let result = maybe_execute_chatgpt_web_image_sync(
&state,
&plan,
Some(&json!({"chatgpt_web_image": true})),
)
.await
.expect("executor should run")
.expect("plan should be intercepted");
assert_eq!(result.status_code, 400);
let body = execution_result_json(&result).expect("error should be json");
assert_eq!(body["error"]["type"], "invalid_request_error");
assert_eq!(body["error"]["code"], "chatgpt_web_image_unsupported");
}
#[tokio::test]
async fn chatgpt_web_image_executor_accepts_marked_responses_client_plan() {
let state = crate::AppState::new().expect("state should build");
let mut plan = sample_plan(
CHATGPT_WEB_DEFAULT_BASE_URL,
json!({
"error": {
"message": "ChatGPT-Web 不支持该分辨率",
"type": "invalid_request_error",
"code": "chatgpt_web_image_unsupported"
}
}),
false,
);
plan.client_api_format = "openai:responses".to_string();
let result = maybe_execute_chatgpt_web_image_sync(&state, &plan, None)
.await
.expect("executor should run")
.expect("marked image provider plan should be intercepted");
assert_eq!(result.status_code, 400);
let body = execution_result_json(&result).expect("error should be json");
assert_eq!(body["error"]["code"], "chatgpt_web_image_unsupported");
}
#[tokio::test]
async fn chatgpt_web_image_stream_path_wraps_executor_result_as_ndjson_frames() {
let state = crate::AppState::new().expect("state should build");
let plan = sample_plan(
CHATGPT_WEB_DEFAULT_BASE_URL,
json!({
"error": {
"message": "ChatGPT-Web 不支持该分辨率",
"type": "invalid_request_error",
"code": "chatgpt_web_image_unsupported"
}
}),
true,
);
let stream = maybe_execute_chatgpt_web_image_stream(
&state,
&plan,
Some(&json!({"chatgpt_web_image": true})),
)
.await
.expect("executor should run")
.expect("plan should be intercepted");
let chunks = stream
.frame_stream
.collect::<Vec<_>>()
.await
.into_iter()
.map(|chunk| chunk.expect("frame should encode"))
.collect::<Vec<_>>();
let text = String::from_utf8(
chunks
.iter()
.flat_map(|chunk| chunk.iter().copied())
.collect::<Vec<_>>(),
)
.expect("ndjson should be utf8");
assert!(text.contains("\"status_code\":400"));
let decoded_data = text
.lines()
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
.filter_map(|frame| {
frame
.get("payload")
.and_then(|payload| payload.get("chunk_b64"))
.and_then(Value::as_str)
.and_then(|chunk| base64::engine::general_purpose::STANDARD.decode(chunk).ok())
})
.flat_map(|bytes| String::from_utf8(bytes).ok())
.collect::<String>();
assert!(decoded_data.contains("chatgpt_web_image_unsupported"));
assert!(text.contains("\"type\":\"eof\""));
}
}