2026-05-06 02:29:17 +08:00
|
|
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
|
|
|
|
use std::io::Error as IoError;
|
2026-05-23 21:28:13 +08:00
|
|
|
|
use std::time::{Duration, Instant};
|
2026-05-06 02:29:17 +08:00
|
|
|
|
|
2026-05-23 17:40:44 +08:00
|
|
|
|
use aether_admin::provider::quota::{
|
|
|
|
|
|
parse_chatgpt_web_conversation_init_response, quota_refresh_success_invalid_state,
|
|
|
|
|
|
};
|
2026-05-06 02:29:17 +08:00
|
|
|
|
use aether_contracts::{
|
2026-05-18 01:32:45 +08:00
|
|
|
|
ExecutionPlan, ExecutionResult, ExecutionStreamTerminalSummary, ExecutionTelemetry,
|
2026-05-23 17:40:44 +08:00
|
|
|
|
ExecutionTimeouts, ProxySnapshot, RequestBody, ResolvedTransportProfile, ResponseBody,
|
|
|
|
|
|
StreamFrame, StreamFramePayload, StreamFrameType,
|
|
|
|
|
|
EXECUTION_REQUEST_ACCEPT_INVALID_CERTS_HEADER, EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER,
|
|
|
|
|
|
TRANSPORT_BACKEND_BROWSER_WREQ, TRANSPORT_HTTP_MODE_AUTO, TRANSPORT_POOL_SCOPE_KEY,
|
|
|
|
|
|
};
|
2026-07-22 02:09:34 +08:00
|
|
|
|
use aether_data_contracts::repository::provider_catalog::ProviderCatalogKeyRuntimeMetadataUpdate;
|
2026-05-23 17:40:44 +08:00
|
|
|
|
use aether_provider_pool::{
|
|
|
|
|
|
build_chatgpt_web_pool_quota_request, normalize_chatgpt_web_image_quota_limit,
|
|
|
|
|
|
ProviderPoolQuotaRequestSpec,
|
2026-05-06 02:29:17 +08:00
|
|
|
|
};
|
|
|
|
|
|
use axum::body::Bytes;
|
|
|
|
|
|
use base64::Engine as _;
|
|
|
|
|
|
use chrono::{FixedOffset, Utc};
|
|
|
|
|
|
use futures_util::stream::{self, BoxStream};
|
|
|
|
|
|
use futures_util::StreamExt;
|
2026-05-18 01:32:45 +08:00
|
|
|
|
use serde_json::{json, Map, Value};
|
2026-05-23 17:40:44 +08:00
|
|
|
|
use tracing::{debug, warn};
|
2026-05-06 02:29:17 +08:00
|
|
|
|
use uuid::Uuid;
|
|
|
|
|
|
|
2026-05-18 01:32:45 +08:00
|
|
|
|
use crate::ai_serving::api::StreamingStandardTerminalObserver;
|
2026-05-06 02:29:17 +08:00
|
|
|
|
use crate::clock::current_unix_secs;
|
|
|
|
|
|
use crate::execution_runtime::ndjson::encode_stream_frame_ndjson;
|
|
|
|
|
|
use crate::execution_runtime::transport::{
|
2026-05-25 15:23:02 +08:00
|
|
|
|
with_non_stream_total_timeout, DirectSyncExecutionRuntime, ExecutionRuntimeTransportError,
|
2026-05-06 02:29:17 +08:00
|
|
|
|
};
|
2026-05-23 17:40:44 +08:00
|
|
|
|
use crate::handlers::shared::{
|
|
|
|
|
|
sync_provider_key_oauth_status_snapshot, sync_provider_key_quota_status_snapshot,
|
|
|
|
|
|
};
|
2026-05-06 02:29:17 +08:00
|
|
|
|
use crate::AppState;
|
|
|
|
|
|
|
|
|
|
|
|
const CHATGPT_WEB_INTERNAL_HEADER: &str = "x-aether-chatgpt-web-image";
|
|
|
|
|
|
const CHATGPT_WEB_DEFAULT_BASE_URL: &str = "https://chatgpt.com";
|
|
|
|
|
|
const CHATGPT_WEB_USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0";
|
|
|
|
|
|
const CHATGPT_WEB_CLIENT_VERSION: &str = "prod-be885abbfcfe7b1f511e88b3003d9ee44757fbad";
|
|
|
|
|
|
const CHATGPT_WEB_BUILD_NUMBER: &str = "5955942";
|
|
|
|
|
|
const CHATGPT_WEB_SEC_CH_UA: &str =
|
|
|
|
|
|
r#""Microsoft Edge";v="143", "Chromium";v="143", "Not A(Brand";v="24""#;
|
2026-05-16 19:23:23 +08:00
|
|
|
|
const CHATGPT_WEB_BROWSER_PROFILE: &str = "chrome143";
|
2026-05-23 17:40:44 +08:00
|
|
|
|
const CHATGPT_WEB_QUOTA_REFRESH_TIMEOUT_MS: u64 = 30_000;
|
|
|
|
|
|
const CHATGPT_WEB_QUOTA_REFRESH_PROXY_TIMEOUT_MS: u64 = 60_000;
|
2026-07-22 02:09:34 +08:00
|
|
|
|
const RUNTIME_METADATA_CAS_MAX_ATTEMPTS: usize = 16;
|
2026-05-24 18:15:29 +08:00
|
|
|
|
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;
|
2026-05-06 02:29:17 +08:00
|
|
|
|
|
|
|
|
|
|
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)]
|
|
|
|
|
|
struct DownloadedImage {
|
|
|
|
|
|
b64_json: String,
|
|
|
|
|
|
mime: String,
|
|
|
|
|
|
width: Option<u32>,
|
|
|
|
|
|
height: Option<u32>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-05-25 15:23:02 +08:00
|
|
|
|
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,
|
2026-07-30 01:03:05 +08:00
|
|
|
|
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),
|
2026-05-25 15:23:02 +08:00
|
|
|
|
};
|
|
|
|
|
|
Ok(Some(result))
|
|
|
|
|
|
})
|
|
|
|
|
|
.await
|
2026-05-06 02:29:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
2026-07-30 01:03:05 +08:00
|
|
|
|
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),
|
2026-05-06 02:29:17 +08:00
|
|
|
|
};
|
|
|
|
|
|
Ok(Some(ChatGptWebImageStream {
|
2026-05-18 01:32:45 +08:00
|
|
|
|
frame_stream: execution_result_frame_stream(plan, &result, report_context),
|
2026-05-06 02:29:17 +08:00
|
|
|
|
report_context: report_context.cloned(),
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn is_chatgpt_web_image_plan(plan: &ExecutionPlan, report_context: Option<&Value>) -> bool {
|
2026-05-17 21:19:17 +08:00
|
|
|
|
if !plan
|
|
|
|
|
|
.provider_api_format
|
|
|
|
|
|
.eq_ignore_ascii_case("openai:image")
|
2026-05-06 02:29:17 +08:00
|
|
|
|
{
|
|
|
|
|
|
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,
|
|
|
|
|
|
base_url = %base_url,
|
2026-06-08 13:22:08 +08:00
|
|
|
|
operation = %request.operation,
|
2026-05-06 02:29:17 +08:00
|
|
|
|
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?;
|
2026-05-25 01:56:25 +08:00
|
|
|
|
apply_chatgpt_web_image_quota_request_delta_after_conversation_start(state, plan).await;
|
2026-05-24 00:45:09 +08:00
|
|
|
|
spawn_chatgpt_web_image_quota_refresh_after_request(state, plan, &base_url, token.as_str());
|
2026-05-06 02:29:17 +08:00
|
|
|
|
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())
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}),
|
|
|
|
|
|
)
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
Ok(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 {
|
2026-06-08 13:22:08 +08:00
|
|
|
|
operation: String,
|
2026-05-06 02:29:17 +08:00
|
|
|
|
model: String,
|
|
|
|
|
|
web_model: String,
|
|
|
|
|
|
prompt: String,
|
|
|
|
|
|
size: String,
|
|
|
|
|
|
ratio: String,
|
|
|
|
|
|
output_format: String,
|
2026-05-24 18:15:29 +08:00
|
|
|
|
quality: Option<String>,
|
|
|
|
|
|
partial_images: u64,
|
2026-05-06 02:29:17 +08:00
|
|
|
|
images: Vec<String>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl ChatGptWebImageRequest {
|
|
|
|
|
|
fn from_body(body: &Value) -> Result<Self, ExecutionRuntimeTransportError> {
|
|
|
|
|
|
let text = |key: &str| {
|
|
|
|
|
|
body.get(key)
|
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
|
.map(ToOwned::to_owned)
|
|
|
|
|
|
};
|
|
|
|
|
|
let images = body
|
|
|
|
|
|
.get("images")
|
|
|
|
|
|
.and_then(Value::as_array)
|
|
|
|
|
|
.into_iter()
|
|
|
|
|
|
.flatten()
|
|
|
|
|
|
.filter_map(Value::as_str)
|
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
|
.map(ToOwned::to_owned)
|
|
|
|
|
|
.collect::<Vec<_>>();
|
|
|
|
|
|
Ok(Self {
|
2026-06-08 13:22:08 +08:00
|
|
|
|
operation: chatgpt_web_image_operation(body.get("operation")),
|
2026-05-06 02:29:17 +08:00
|
|
|
|
model: text("model").unwrap_or_else(|| "gpt-image-2".to_string()),
|
|
|
|
|
|
web_model: text("web_model").unwrap_or_else(|| "gpt-5-5-thinking".to_string()),
|
|
|
|
|
|
prompt: text("prompt").unwrap_or_else(|| "Generate a high quality image.".to_string()),
|
|
|
|
|
|
size: text("size").unwrap_or_else(|| "1024x1024".to_string()),
|
|
|
|
|
|
ratio: text("ratio").unwrap_or_else(|| "1:1".to_string()),
|
|
|
|
|
|
output_format: text("output_format").unwrap_or_else(|| "png".to_string()),
|
2026-05-24 18:15:29 +08:00
|
|
|
|
quality: text("quality"),
|
|
|
|
|
|
partial_images: json_u64(body.get("partial_images")).unwrap_or(0),
|
2026-05-06 02:29:17 +08:00
|
|
|
|
images,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 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()).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 = %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);
|
|
|
|
|
|
for file_id in &summary.file_ids {
|
|
|
|
|
|
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) = summary.conversation_id.as_deref() {
|
|
|
|
|
|
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) = summary.conversation_id.as_deref() {
|
|
|
|
|
|
for sediment_id in &summary.sediment_ids {
|
|
|
|
|
|
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)
|
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
|
.map(ToOwned::to_owned))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async fn web_download_image(
|
|
|
|
|
|
_state: &AppState,
|
|
|
|
|
|
plan: &ExecutionPlan,
|
|
|
|
|
|
base_url: &str,
|
|
|
|
|
|
fp: &WebFingerprint,
|
|
|
|
|
|
token: &str,
|
|
|
|
|
|
raw_url: &str,
|
|
|
|
|
|
) -> Result<DownloadedImage, ExecutionRuntimeTransportError> {
|
|
|
|
|
|
if let Some(data) = parse_data_url(raw_url) {
|
|
|
|
|
|
return Ok(data);
|
|
|
|
|
|
}
|
|
|
|
|
|
let download_url = if raw_url.starts_with('/') {
|
|
|
|
|
|
format!("{base_url}{raw_url}")
|
|
|
|
|
|
} else {
|
|
|
|
|
|
raw_url.to_string()
|
|
|
|
|
|
};
|
|
|
|
|
|
let mut headers = BTreeMap::from([(
|
|
|
|
|
|
EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER.to_string(),
|
|
|
|
|
|
"true".to_string(),
|
|
|
|
|
|
)]);
|
|
|
|
|
|
if should_use_web_download_headers(base_url, download_url.as_str()) {
|
|
|
|
|
|
let path = url::Url::parse(download_url.as_str())
|
|
|
|
|
|
.ok()
|
|
|
|
|
|
.map(|url| url.path().to_string())
|
|
|
|
|
|
.filter(|path| !path.is_empty())
|
|
|
|
|
|
.unwrap_or_else(|| "/".to_string());
|
|
|
|
|
|
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", download_url, headers, None, false).await?;
|
|
|
|
|
|
ensure_success(&result, "ChatGPT-Web image download")?;
|
|
|
|
|
|
let data = execution_result_bytes(&result)?;
|
|
|
|
|
|
if data.is_empty() {
|
|
|
|
|
|
return Err(ExecutionRuntimeTransportError::UpstreamRequest(
|
|
|
|
|
|
"ChatGPT-Web image download returned empty body".to_string(),
|
|
|
|
|
|
));
|
|
|
|
|
|
}
|
|
|
|
|
|
let mime = result
|
|
|
|
|
|
.headers
|
|
|
|
|
|
.get("content-type")
|
|
|
|
|
|
.and_then(|value| value.split(';').next())
|
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
|
.filter(|value| value.starts_with("image/"))
|
|
|
|
|
|
.unwrap_or("image/png")
|
|
|
|
|
|
.to_string();
|
|
|
|
|
|
let (width, height) = image_dimensions(&data);
|
|
|
|
|
|
Ok(DownloadedImage {
|
|
|
|
|
|
b64_json: base64::engine::general_purpose::STANDARD.encode(data),
|
|
|
|
|
|
mime,
|
|
|
|
|
|
width,
|
|
|
|
|
|
height,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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).await?;
|
|
|
|
|
|
let bytes = base64::engine::general_purpose::STANDARD
|
|
|
|
|
|
.decode(image.b64_json.as_bytes())
|
|
|
|
|
|
.map_err(ExecutionRuntimeTransportError::BodyDecode)?;
|
|
|
|
|
|
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 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(),
|
|
|
|
|
|
)
|
|
|
|
|
|
})?
|
|
|
|
|
|
.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 put_headers = BTreeMap::from([
|
|
|
|
|
|
("content-type".to_string(), image.mime.clone()),
|
|
|
|
|
|
("x-ms-blob-type".to_string(), "BlockBlob".to_string()),
|
|
|
|
|
|
("x-ms-version".to_string(), "2020-04-08".to_string()),
|
|
|
|
|
|
("origin".to_string(), base_url.to_string()),
|
|
|
|
|
|
("referer".to_string(), format!("{base_url}/")),
|
|
|
|
|
|
("user-agent".to_string(), fp.user_agent.to_string()),
|
|
|
|
|
|
]);
|
|
|
|
|
|
let put_result = execute_subrequest(
|
|
|
|
|
|
plan,
|
|
|
|
|
|
"PUT",
|
|
|
|
|
|
upload_url.to_string(),
|
|
|
|
|
|
put_headers,
|
|
|
|
|
|
Some(RequestBody {
|
|
|
|
|
|
json_body: None,
|
|
|
|
|
|
body_bytes_b64: Some(base64::engine::general_purpose::STANDARD.encode(&bytes)),
|
|
|
|
|
|
body_ref: None,
|
|
|
|
|
|
}),
|
|
|
|
|
|
false,
|
|
|
|
|
|
)
|
|
|
|
|
|
.await?;
|
|
|
|
|
|
ensure_success(&put_result, "ChatGPT-Web upload blob")?;
|
|
|
|
|
|
|
|
|
|
|
|
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: bytes.len(),
|
|
|
|
|
|
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| !value.is_empty())
|
|
|
|
|
|
.map(ToOwned::to_owned)
|
|
|
|
|
|
})
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async fn execute_subrequest(
|
|
|
|
|
|
plan: &ExecutionPlan,
|
|
|
|
|
|
method: &str,
|
|
|
|
|
|
url: String,
|
|
|
|
|
|
mut headers: BTreeMap<String, String>,
|
|
|
|
|
|
body: Option<RequestBody>,
|
|
|
|
|
|
stream: bool,
|
|
|
|
|
|
) -> Result<ExecutionResult, ExecutionRuntimeTransportError> {
|
|
|
|
|
|
headers.insert(
|
|
|
|
|
|
EXECUTION_REQUEST_ACCEPT_INVALID_CERTS_HEADER.to_string(),
|
|
|
|
|
|
"true".to_string(),
|
|
|
|
|
|
);
|
|
|
|
|
|
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(),
|
2026-05-16 19:23:23 +08:00
|
|
|
|
transport_profile: chatgpt_web_image_transport_profile(plan),
|
2026-05-06 02:29:17 +08:00
|
|
|
|
timeouts: plan.timeouts.clone(),
|
|
|
|
|
|
};
|
|
|
|
|
|
DirectSyncExecutionRuntime::new()
|
|
|
|
|
|
.execute_sync(&subplan)
|
|
|
|
|
|
.await
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-25 01:56:25 +08:00
|
|
|
|
async fn apply_chatgpt_web_image_quota_request_delta_after_conversation_start(
|
2026-05-24 00:45:09 +08:00
|
|
|
|
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,
|
2026-05-25 01:56:25 +08:00
|
|
|
|
"gateway persisted ChatGPT-Web image quota request delta after conversation start"
|
2026-05-24 00:45:09 +08:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
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,
|
2026-05-25 01:56:25 +08:00
|
|
|
|
"gateway skipped ChatGPT-Web image quota request delta after conversation start"
|
2026-05-24 00:45:09 +08:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
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,
|
2026-05-25 01:56:25 +08:00
|
|
|
|
"gateway failed to persist ChatGPT-Web image quota request delta after conversation start"
|
2026-05-24 00:45:09 +08:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn spawn_chatgpt_web_image_quota_refresh_after_request(
|
2026-05-23 17:40:44 +08:00
|
|
|
|
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 {
|
2026-05-23 21:28:13 +08:00
|
|
|
|
tokio::time::sleep(Duration::from_secs(5)).await;
|
2026-05-23 17:40:44 +08:00
|
|
|
|
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,
|
2026-05-23 22:14:51 +08:00
|
|
|
|
"gateway failed to refresh ChatGPT-Web image quota after a generation request"
|
2026-05-23 17:40:44 +08:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-23 22:14:51 +08:00
|
|
|
|
async fn apply_chatgpt_web_image_quota_request_delta(
|
2026-05-23 21:28:13 +08:00
|
|
|
|
state: &AppState,
|
|
|
|
|
|
plan: &ExecutionPlan,
|
|
|
|
|
|
) -> Result<bool, String> {
|
|
|
|
|
|
let key_id = plan.key_id.trim();
|
|
|
|
|
|
let provider_id = plan.provider_id.trim();
|
2026-05-24 00:45:09 +08:00
|
|
|
|
if key_id.is_empty() || provider_id.is_empty() {
|
|
|
|
|
|
return Ok(false);
|
|
|
|
|
|
}
|
2026-05-24 03:26:30 +08:00
|
|
|
|
let request_dedup_key = chatgpt_web_image_quota_request_delta_dedup_key(plan);
|
2026-07-22 02:09:34 +08:00
|
|
|
|
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(|err| err.into_message())?
|
|
|
|
|
|
.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 = 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(|err| err.into_message())?;
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-05-23 21:28:13 +08:00
|
|
|
|
}
|
2026-07-22 02:09:34 +08:00
|
|
|
|
Ok(false)
|
2026-05-23 21:28:13 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-23 22:14:51 +08:00
|
|
|
|
fn apply_chatgpt_web_image_quota_request_delta_to_metadata(
|
|
|
|
|
|
metadata: &mut Map<String, Value>,
|
|
|
|
|
|
status_snapshot: Option<&Value>,
|
|
|
|
|
|
now_unix_secs: u64,
|
2026-05-24 03:26:30 +08:00
|
|
|
|
request_dedup_key: Option<&str>,
|
2026-05-23 22:14:51 +08:00
|
|
|
|
) -> bool {
|
2026-05-24 03:26:30 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-23 22:14:51 +08:00
|
|
|
|
let snapshot_window = chatgpt_web_image_quota_snapshot_window(status_snapshot);
|
2026-05-24 00:45:09 +08:00
|
|
|
|
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);
|
2026-05-23 22:14:51 +08:00
|
|
|
|
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")))
|
|
|
|
|
|
})
|
2026-05-24 00:45:09 +08:00
|
|
|
|
.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,
|
|
|
|
|
|
);
|
2026-05-24 13:58:54 +08:00
|
|
|
|
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");
|
|
|
|
|
|
}
|
2026-05-24 00:45:09 +08:00
|
|
|
|
let limit_value = limit
|
|
|
|
|
|
.as_ref()
|
|
|
|
|
|
.map(|limit| limit.value)
|
2026-05-24 13:58:54 +08:00
|
|
|
|
.unwrap_or_else(|| remaining.unwrap_or(0.0).max(0.0));
|
2026-05-23 22:14:51 +08:00
|
|
|
|
|
2026-05-24 00:45:09 +08:00
|
|
|
|
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));
|
|
|
|
|
|
}
|
2026-05-24 13:58:54 +08:00
|
|
|
|
}
|
|
|
|
|
|
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)),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-23 22:14:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
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),
|
|
|
|
|
|
);
|
2026-05-24 03:26:30 +08:00
|
|
|
|
if let Some(request_dedup_key) = request_dedup_key {
|
|
|
|
|
|
metadata.insert(
|
|
|
|
|
|
"image_quota_last_local_request_key".to_string(),
|
|
|
|
|
|
json!(request_dedup_key),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-05-23 22:14:51 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 03:26:30 +08:00
|
|
|
|
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(),
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 00:45:09 +08:00
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
|
struct ChatGptWebImageQuotaRequestLimit {
|
|
|
|
|
|
value: f64,
|
|
|
|
|
|
source: Option<String>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 13:58:54 +08:00
|
|
|
|
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,
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 00:45:09 +08:00
|
|
|
|
fn chatgpt_web_image_quota_request_limit_choice(
|
|
|
|
|
|
metadata: &Map<String, Value>,
|
|
|
|
|
|
status_snapshot: Option<&Value>,
|
|
|
|
|
|
metadata_limit: Option<f64>,
|
|
|
|
|
|
snapshot_limit: Option<f64>,
|
2026-05-24 13:58:54 +08:00
|
|
|
|
remaining: Option<f64>,
|
2026-05-24 00:45:09 +08:00
|
|
|
|
) -> 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"))
|
2026-05-24 13:58:54 +08:00
|
|
|
|
&& remaining.is_some_and(|remaining| (limit - remaining).abs() <= f64::EPSILON);
|
2026-05-24 00:45:09 +08:00
|
|
|
|
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
|
2026-05-24 13:58:54 +08:00
|
|
|
|
.filter(|remaining| remaining.is_finite() && *remaining > 0.0)
|
|
|
|
|
|
.map(|remaining| ChatGptWebImageQuotaRequestLimit {
|
|
|
|
|
|
value: remaining,
|
2026-05-24 00:45:09 +08:00
|
|
|
|
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>,
|
2026-05-24 13:58:54 +08:00
|
|
|
|
remaining: Option<f64>,
|
2026-05-24 00:45:09 +08:00
|
|
|
|
) -> 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;
|
|
|
|
|
|
}
|
2026-05-24 13:58:54 +08:00
|
|
|
|
remaining.is_none_or(|remaining| remaining.is_finite() && remaining < limit)
|
2026-05-24 00:45:09 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-23 17:40:44 +08:00
|
|
|
|
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(|err| err.into_message())?
|
|
|
|
|
|
.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(|err| err.into_message())?
|
|
|
|
|
|
.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("a_plan)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.map_err(|err| err.to_string())?;
|
|
|
|
|
|
if result.status_code != 200 {
|
|
|
|
|
|
let body_excerpt = String::from_utf8_lossy(&execution_result_body_bytes_lossy(&result))
|
|
|
|
|
|
.chars()
|
|
|
|
|
|
.take(320)
|
|
|
|
|
|
.collect::<String>();
|
|
|
|
|
|
return Err(format!(
|
|
|
|
|
|
"conversation/init returned {}: {}",
|
|
|
|
|
|
result.status_code, body_excerpt
|
|
|
|
|
|
));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let body_json = execution_result_json(&result).map_err(|err| err.to_string())?;
|
2026-05-23 21:28:13 +08:00
|
|
|
|
let now_unix_secs = current_unix_secs();
|
2026-07-22 02:09:34 +08:00
|
|
|
|
let Some(metadata) = parse_chatgpt_web_conversation_init_response(&body_json, now_unix_secs)
|
2026-05-23 17:40:44 +08:00
|
|
|
|
else {
|
|
|
|
|
|
return Ok(false);
|
|
|
|
|
|
};
|
|
|
|
|
|
let Some(latest_key) = state
|
|
|
|
|
|
.read_provider_catalog_keys_by_ids(&key_ids)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.map_err(|err| err.into_message())?
|
|
|
|
|
|
.into_iter()
|
|
|
|
|
|
.find(|key| key.id == key_id && key.provider_id == provider_id)
|
|
|
|
|
|
else {
|
|
|
|
|
|
return Ok(false);
|
|
|
|
|
|
};
|
2026-07-22 02:09:34 +08:00
|
|
|
|
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();
|
2026-05-23 17:40:44 +08:00
|
|
|
|
normalize_chatgpt_web_image_quota_limit(&mut metadata, latest_key.upstream_metadata.as_ref());
|
|
|
|
|
|
|
|
|
|
|
|
let mut updated_key = latest_key;
|
2026-07-22 02:09:34 +08:00
|
|
|
|
let namespace_value = metadata.clone();
|
2026-05-23 17:40:44 +08:00
|
|
|
|
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);
|
2026-05-23 21:28:13 +08:00
|
|
|
|
updated_key.updated_at_unix_secs = Some(now_unix_secs);
|
2026-05-23 17:40:44 +08:00
|
|
|
|
|
2026-07-22 02:09:34 +08:00
|
|
|
|
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,
|
|
|
|
|
|
})
|
2026-05-23 17:40:44 +08:00
|
|
|
|
.await
|
2026-07-22 02:09:34 +08:00
|
|
|
|
.map_err(|err| err.into_message())?;
|
|
|
|
|
|
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(),
|
|
|
|
|
|
None,
|
|
|
|
|
|
updated_key.updated_at_unix_secs,
|
|
|
|
|
|
)
|
|
|
|
|
|
.await
|
|
|
|
|
|
.map_err(|err| err.into_message());
|
|
|
|
|
|
}
|
|
|
|
|
|
// 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)
|
2026-05-23 17:40:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn build_chatgpt_web_image_quota_refresh_plan(
|
|
|
|
|
|
plan: &ExecutionPlan,
|
|
|
|
|
|
spec: ProviderPoolQuotaRequestSpec,
|
|
|
|
|
|
) -> ExecutionPlan {
|
|
|
|
|
|
let ProviderPoolQuotaRequestSpec {
|
|
|
|
|
|
request_id,
|
|
|
|
|
|
provider_name,
|
|
|
|
|
|
quota_kind: _,
|
|
|
|
|
|
method,
|
|
|
|
|
|
url,
|
|
|
|
|
|
mut headers,
|
|
|
|
|
|
content_type,
|
|
|
|
|
|
json_body,
|
|
|
|
|
|
client_api_format,
|
|
|
|
|
|
provider_api_format,
|
|
|
|
|
|
model_name,
|
|
|
|
|
|
accept_invalid_certs,
|
|
|
|
|
|
} = spec;
|
|
|
|
|
|
if accept_invalid_certs {
|
|
|
|
|
|
headers.insert(
|
|
|
|
|
|
EXECUTION_REQUEST_ACCEPT_INVALID_CERTS_HEADER.to_string(),
|
|
|
|
|
|
"true".to_string(),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
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))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 02:09:34 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-23 22:14:51 +08:00
|
|
|
|
fn chatgpt_web_image_quota_snapshot_window(
|
|
|
|
|
|
status_snapshot: Option<&Value>,
|
|
|
|
|
|
) -> Option<&Map<String, Value>> {
|
2026-05-24 00:45:09 +08:00
|
|
|
|
let quota = chatgpt_web_image_quota_snapshot(status_snapshot)?;
|
2026-05-23 22:14:51 +08:00
|
|
|
|
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"))
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 00:45:09 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-23 21:28:13 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-16 19:23:23 +08:00
|
|
|
|
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",
|
|
|
|
|
|
})),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-06 02:29:17 +08:00
|
|
|
|
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(value.clone());
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(text) = extract_assistant_text(&value) {
|
|
|
|
|
|
summary.last_text = Some(text);
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(result) = value
|
|
|
|
|
|
.get("item")
|
|
|
|
|
|
.filter(|item| {
|
|
|
|
|
|
item.get("type").and_then(Value::as_str) == Some("image_generation_call")
|
|
|
|
|
|
})
|
|
|
|
|
|
.and_then(|item| item.get("result"))
|
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
|
{
|
|
|
|
|
|
add_unique_values(
|
|
|
|
|
|
&mut summary.direct_urls,
|
|
|
|
|
|
[format!("data:image/png;base64,{result}")],
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
add_unique_values(
|
|
|
|
|
|
&mut summary.direct_urls,
|
|
|
|
|
|
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(),
|
|
|
|
|
|
);
|
|
|
|
|
|
add_unique_values(&mut urls, [format!("data:{mime};base64,{partial_b64}")]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
_ => {
|
|
|
|
|
|
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())
|
|
|
|
|
|
{
|
|
|
|
|
|
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(),
|
|
|
|
|
|
);
|
|
|
|
|
|
Some(format!("data:{mime};base64,{b64}"))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn mime_for_web_output_format(format: &str) -> &'static str {
|
|
|
|
|
|
match format.trim().to_ascii_lowercase().as_str() {
|
|
|
|
|
|
"jpeg" | "jpg" => "image/jpeg",
|
|
|
|
|
|
"webp" => "image/webp",
|
|
|
|
|
|
_ => "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| !value.is_empty())
|
|
|
|
|
|
{
|
|
|
|
|
|
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 text.starts_with("sediment://") {
|
|
|
|
|
|
add_unique_values(
|
|
|
|
|
|
&mut summary.sediment_ids,
|
|
|
|
|
|
[text.trim_start_matches("sediment://").to_string()],
|
|
|
|
|
|
);
|
|
|
|
|
|
} else if is_web_file_id(text) {
|
|
|
|
|
|
add_unique_values(&mut summary.file_ids, [text.to_string()]);
|
|
|
|
|
|
} else if is_generated_web_asset_url(text) || text.starts_with("data:image/") {
|
|
|
|
|
|
add_unique_values(&mut summary.direct_urls, [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())
|
|
|
|
|
|
.map(ToOwned::to_owned)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn merge_web_summary(target: &mut WebImageSseSummary, source: &mut WebImageSseSummary) {
|
|
|
|
|
|
if target.conversation_id.is_none() {
|
|
|
|
|
|
target.conversation_id = source.conversation_id.take();
|
|
|
|
|
|
}
|
|
|
|
|
|
add_unique_values(&mut target.file_ids, source.file_ids.drain(..));
|
|
|
|
|
|
add_unique_values(&mut target.sediment_ids, source.sediment_ids.drain(..));
|
|
|
|
|
|
add_unique_values(&mut target.direct_urls, 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>) {
|
|
|
|
|
|
for value in incoming {
|
|
|
|
|
|
if !value.is_empty() && !values.iter().any(|existing| existing == &value) {
|
|
|
|
|
|
values.push(value);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn build_success_sse(
|
|
|
|
|
|
request: &ChatGptWebImageRequest,
|
|
|
|
|
|
image: &DownloadedImage,
|
2026-05-24 17:58:00 +08:00
|
|
|
|
report_context: Option<&Value>,
|
2026-05-06 02:29:17 +08:00
|
|
|
|
) -> 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());
|
2026-05-24 17:58:00 +08:00
|
|
|
|
let usage = chatgpt_web_image_usage(request, image, report_context);
|
2026-05-06 02:29:17 +08:00
|
|
|
|
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
|
|
|
|
|
|
}],
|
2026-05-24 17:58:00 +08:00
|
|
|
|
"usage": usage.0,
|
|
|
|
|
|
"tool_usage": usage.1
|
2026-05-06 02:29:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
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
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 17:58:00 +08:00
|
|
|
|
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);
|
2026-05-24 18:15:29 +08:00
|
|
|
|
let estimated_output_tokens = chatgpt_web_image_output_tokens(request, image, report_context);
|
2026-05-24 17:58:00 +08:00
|
|
|
|
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": {
|
2026-05-24 18:15:29 +08:00
|
|
|
|
"image_tokens": estimated_output_tokens,
|
|
|
|
|
|
"text_tokens": 0
|
2026-05-24 17:58:00 +08:00
|
|
|
|
},
|
|
|
|
|
|
"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())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 18:15:29 +08:00
|
|
|
|
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())
|
2026-05-24 17:58:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 18:15:29 +08:00
|
|
|
|
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)))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 17:58:00 +08:00
|
|
|
|
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)))
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 18:15:29 +08:00
|
|
|
|
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)))
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 17:58:00 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 18:15:29 +08:00
|
|
|
|
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 {
|
2026-05-25 01:56:25 +08:00
|
|
|
|
width.is_multiple_of(16)
|
|
|
|
|
|
&& height.is_multiple_of(16)
|
|
|
|
|
|
&& gpt_image2_dimensions_are_plausible(width, height)
|
2026-05-24 18:15:29 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 17:58:00 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 18:15:29 +08:00
|
|
|
|
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())
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-08 13:22:08 +08:00
|
|
|
|
fn chatgpt_web_image_operation(value: Option<&Value>) -> String {
|
|
|
|
|
|
value
|
|
|
|
|
|
.and_then(Value::as_str)
|
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
|
.map(str::to_ascii_lowercase)
|
|
|
|
|
|
.filter(|value| matches!(value.as_str(), "generate" | "edit"))
|
|
|
|
|
|
.unwrap_or_else(|| "generate".to_string())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-06 02:29:17 +08:00
|
|
|
|
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 {
|
2026-06-08 13:22:08 +08:00
|
|
|
|
let operation = match request.operation.as_str() {
|
|
|
|
|
|
"edit" => "edit",
|
|
|
|
|
|
_ => "generation",
|
|
|
|
|
|
};
|
2026-05-06 02:29:17 +08:00
|
|
|
|
json!({
|
|
|
|
|
|
"type": "response.failed",
|
|
|
|
|
|
"response": {
|
|
|
|
|
|
"status": "failed",
|
|
|
|
|
|
"model": request.model,
|
|
|
|
|
|
"error": failure.get("error").cloned().unwrap_or_else(|| json!({
|
|
|
|
|
|
"code": "chatgpt_web_image_failed",
|
2026-06-08 13:22:08 +08:00
|
|
|
|
"message": format!("ChatGPT-Web image {operation} failed")
|
2026-05-06 02:29:17 +08:00
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
};
|
|
|
|
|
|
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())]),
|
|
|
|
|
|
body: Some(ResponseBody {
|
|
|
|
|
|
json_body: Some(body),
|
|
|
|
|
|
body_bytes_b64: None,
|
|
|
|
|
|
}),
|
|
|
|
|
|
telemetry: Some(telemetry(started_at, 0)),
|
|
|
|
|
|
error: None,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-30 01:03:05 +08:00
|
|
|
|
fn chatgpt_web_http_error_execution_result(
|
2026-05-06 02:29:17 +08:00
|
|
|
|
plan: &ExecutionPlan,
|
|
|
|
|
|
started_at: Instant,
|
2026-07-30 01:03:05 +08:00
|
|
|
|
status_code: u16,
|
|
|
|
|
|
message: &str,
|
2026-05-06 02:29:17 +08:00
|
|
|
|
) -> ExecutionResult {
|
|
|
|
|
|
json_execution_result(
|
|
|
|
|
|
plan,
|
2026-07-30 01:03:05 +08:00
|
|
|
|
status_code,
|
2026-05-06 02:29:17 +08:00
|
|
|
|
json!({
|
|
|
|
|
|
"error": {
|
|
|
|
|
|
"type": "upstream_error",
|
|
|
|
|
|
"code": "chatgpt_web_image_execution_unavailable",
|
2026-07-30 01:03:05 +08:00
|
|
|
|
"message": message
|
2026-05-06 02:29:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
}),
|
|
|
|
|
|
started_at,
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn bytes_execution_result(
|
|
|
|
|
|
plan: &ExecutionPlan,
|
|
|
|
|
|
status_code: u16,
|
|
|
|
|
|
headers: BTreeMap<String, String>,
|
|
|
|
|
|
body: Vec<u8>,
|
|
|
|
|
|
started_at: Instant,
|
|
|
|
|
|
) -> ExecutionResult {
|
|
|
|
|
|
let body_len = body.len() as u64;
|
|
|
|
|
|
ExecutionResult {
|
|
|
|
|
|
request_id: plan.request_id.clone(),
|
|
|
|
|
|
candidate_id: plan.candidate_id.clone(),
|
|
|
|
|
|
status_code,
|
|
|
|
|
|
headers,
|
|
|
|
|
|
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(
|
2026-05-18 01:32:45 +08:00
|
|
|
|
plan: &ExecutionPlan,
|
2026-05-06 02:29:17 +08:00
|
|
|
|
result: &ExecutionResult,
|
2026-05-18 01:32:45 +08:00
|
|
|
|
report_context: Option<&Value>,
|
2026-05-06 02:29:17 +08:00
|
|
|
|
) -> BoxStream<'static, Result<Bytes, IoError>> {
|
|
|
|
|
|
let body = execution_result_body_bytes_lossy(result);
|
2026-05-18 01:32:45 +08:00
|
|
|
|
let terminal_summary = chatgpt_web_stream_terminal_summary(plan, result, report_context, &body);
|
2026-05-06 02:29:17 +08:00
|
|
|
|
let mut frames = vec![
|
|
|
|
|
|
StreamFrame {
|
|
|
|
|
|
frame_type: StreamFrameType::Headers,
|
|
|
|
|
|
payload: StreamFramePayload::Headers {
|
|
|
|
|
|
status_code: result.status_code,
|
|
|
|
|
|
headers: result.headers.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),
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
];
|
|
|
|
|
|
if !body.is_empty() {
|
|
|
|
|
|
frames.push(StreamFrame {
|
|
|
|
|
|
frame_type: StreamFrameType::Data,
|
|
|
|
|
|
payload: StreamFramePayload::Data {
|
|
|
|
|
|
chunk_b64: Some(base64::engine::general_purpose::STANDARD.encode(body.as_slice())),
|
|
|
|
|
|
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,
|
|
|
|
|
|
}),
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
2026-05-18 01:32:45 +08:00
|
|
|
|
frames.push(StreamFrame::eof_with_summary(terminal_summary));
|
2026-05-06 02:29:17 +08:00
|
|
|
|
stream::iter(
|
|
|
|
|
|
frames
|
|
|
|
|
|
.into_iter()
|
|
|
|
|
|
.map(|frame| encode_stream_frame_ndjson(&frame)),
|
|
|
|
|
|
)
|
|
|
|
|
|
.boxed()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-18 01:32:45 +08:00
|
|
|
|
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(),
|
2026-06-08 13:22:08 +08:00
|
|
|
|
Value::String(chatgpt_web_image_operation(body.get("operation"))),
|
2026-05-18 01:32:45 +08:00
|
|
|
|
);
|
2026-05-24 17:58:00 +08:00
|
|
|
|
for key in [
|
|
|
|
|
|
"model",
|
|
|
|
|
|
"size",
|
|
|
|
|
|
"quality",
|
|
|
|
|
|
"ratio",
|
|
|
|
|
|
"output_format",
|
|
|
|
|
|
"partial_images",
|
|
|
|
|
|
] {
|
2026-05-18 01:32:45 +08:00
|
|
|
|
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()));
|
|
|
|
|
|
}
|
2026-05-24 17:58:00 +08:00
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(value) = body.get(key).and_then(Value::as_u64) {
|
|
|
|
|
|
image_request.insert(key.to_string(), Value::Number(value.into()));
|
2026-05-18 01:32:45 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
Some(Value::Object(image_request))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-06 02:29:17 +08:00
|
|
|
|
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> {
|
|
|
|
|
|
Ok(execution_result_body_bytes_lossy(result))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn execution_result_body_bytes_lossy(result: &ExecutionResult) -> Vec<u8> {
|
|
|
|
|
|
let Some(body) = result.body.as_ref() else {
|
|
|
|
|
|
return Vec::new();
|
|
|
|
|
|
};
|
|
|
|
|
|
if let Some(json_body) = body.json_body.as_ref() {
|
|
|
|
|
|
return serde_json::to_vec(json_body).unwrap_or_default();
|
|
|
|
|
|
}
|
|
|
|
|
|
body.body_bytes_b64
|
|
|
|
|
|
.as_deref()
|
|
|
|
|
|
.and_then(|value| base64::engine::general_purpose::STANDARD.decode(value).ok())
|
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn ensure_success(
|
|
|
|
|
|
result: &ExecutionResult,
|
|
|
|
|
|
stage: &str,
|
|
|
|
|
|
) -> Result<(), ExecutionRuntimeTransportError> {
|
|
|
|
|
|
if (200..300).contains(&result.status_code) {
|
|
|
|
|
|
return Ok(());
|
|
|
|
|
|
}
|
|
|
|
|
|
let body = String::from_utf8_lossy(&execution_result_body_bytes_lossy(result)).to_string();
|
2026-07-30 01:03:05 +08:00
|
|
|
|
Err(ExecutionRuntimeTransportError::UpstreamHttpStatus {
|
|
|
|
|
|
status_code: result.status_code,
|
|
|
|
|
|
message: format!(
|
|
|
|
|
|
"{stage} returned {}: {}",
|
|
|
|
|
|
result.status_code,
|
|
|
|
|
|
body.chars().take(320).collect::<String>()
|
|
|
|
|
|
),
|
|
|
|
|
|
})
|
2026-05-06 02:29:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
};
|
|
|
|
|
|
if diff_bytes.is_empty() {
|
|
|
|
|
|
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>> {
|
|
|
|
|
|
let mut hex = value.trim().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> {
|
|
|
|
|
|
let (header, data) = value.trim().split_once(',')?;
|
|
|
|
|
|
let mime = header
|
|
|
|
|
|
.strip_prefix("data:")
|
|
|
|
|
|
.and_then(|value| value.split(';').next())
|
|
|
|
|
|
.filter(|value| value.starts_with("image/"))
|
|
|
|
|
|
.unwrap_or("image/png")
|
|
|
|
|
|
.to_string();
|
|
|
|
|
|
let bytes = base64::engine::general_purpose::STANDARD
|
|
|
|
|
|
.decode(data)
|
|
|
|
|
|
.ok()?;
|
|
|
|
|
|
let (width, height) = image_dimensions(&bytes);
|
|
|
|
|
|
Some(DownloadedImage {
|
|
|
|
|
|
b64_json: base64::engine::general_purpose::STANDARD.encode(bytes),
|
|
|
|
|
|
mime,
|
|
|
|
|
|
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]]);
|
|
|
|
|
|
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;
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn is_generated_web_asset_url(raw_url: &str) -> bool {
|
|
|
|
|
|
let Ok(url) = url::Url::parse(raw_url.trim()) else {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
};
|
|
|
|
|
|
let Some(host) = url.host_str().map(str::to_ascii_lowercase) else {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
};
|
|
|
|
|
|
let path = url.path().to_ascii_lowercase();
|
|
|
|
|
|
if host.contains("openaiassets.blob.core.windows.net") {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
if path.contains("/$web/chatgpt/") {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
host.contains("files.oaiusercontent.com")
|
|
|
|
|
|
|| host.contains("oaidalleapiprodscus.blob.core.windows.net")
|
|
|
|
|
|
|| (host.ends_with(".blob.core.windows.net") && !path.contains("/$web/"))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn should_use_web_download_headers(base_url: &str, raw_url: &str) -> bool {
|
|
|
|
|
|
let Ok(url) = url::Url::parse(raw_url) else {
|
|
|
|
|
|
return raw_url.starts_with("/backend-api/");
|
|
|
|
|
|
};
|
|
|
|
|
|
if url.path().starts_with("/backend-api/") {
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
let Ok(base) = url::Url::parse(base_url) else {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
};
|
|
|
|
|
|
url.domain() == base.domain()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
mod tests {
|
|
|
|
|
|
use super::*;
|
2026-05-25 01:56:25 +08:00
|
|
|
|
use std::sync::Arc;
|
|
|
|
|
|
|
|
|
|
|
|
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
|
|
|
|
|
|
use aether_data_contracts::repository::provider_catalog::{
|
|
|
|
|
|
ProviderCatalogReadRepository, StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
|
|
|
|
|
|
StoredProviderCatalogProvider,
|
|
|
|
|
|
};
|
2026-05-06 02:29:17 +08:00
|
|
|
|
use axum::body::Body;
|
|
|
|
|
|
use axum::extract::Request;
|
|
|
|
|
|
use axum::routing::any;
|
|
|
|
|
|
use axum::Router;
|
|
|
|
|
|
use futures_util::StreamExt as _;
|
|
|
|
|
|
use http::{Method, StatusCode};
|
|
|
|
|
|
|
2026-05-25 01:56:25 +08:00
|
|
|
|
use crate::data::GatewayDataState;
|
|
|
|
|
|
|
2026-05-06 02:29:17 +08:00
|
|
|
|
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,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-25 01:56:25 +08:00
|
|
|
|
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"])),
|
|
|
|
|
|
Some("test-access-token".to_string()),
|
|
|
|
|
|
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")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 17:58:00 +08:00
|
|
|
|
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")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 18:15:29 +08:00
|
|
|
|
#[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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 17:58:00 +08:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn chatgpt_web_success_sse_includes_estimated_image_usage() {
|
|
|
|
|
|
let request = ChatGptWebImageRequest {
|
2026-06-08 13:22:08 +08:00
|
|
|
|
operation: "generate".to_string(),
|
2026-05-24 17:58:00 +08:00
|
|
|
|
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(),
|
2026-05-24 18:15:29 +08:00
|
|
|
|
quality: Some("low".to_string()),
|
|
|
|
|
|
partial_images: 0,
|
2026-05-24 17:58:00 +08:00
|
|
|
|
images: Vec::new(),
|
|
|
|
|
|
};
|
|
|
|
|
|
let image = DownloadedImage {
|
2026-05-24 18:15:29 +08:00
|
|
|
|
b64_json: "aGVsbG8=".repeat(128),
|
2026-05-24 17:58:00 +08:00
|
|
|
|
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");
|
2026-05-24 18:15:29 +08:00
|
|
|
|
let output_tokens = 196;
|
2026-05-24 17:58:00 +08:00
|
|
|
|
|
|
|
|
|
|
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!(
|
2026-05-24 18:15:29 +08:00
|
|
|
|
completed["tool_usage"]["image_gen"]["output_tokens_details"]["image_tokens"],
|
2026-05-24 17:58:00 +08:00
|
|
|
|
json!(output_tokens)
|
|
|
|
|
|
);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
completed["usage"]["total_tokens"],
|
|
|
|
|
|
json!(input_tokens.saturating_add(output_tokens))
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-05-24 18:15:29 +08:00
|
|
|
|
fn chatgpt_web_success_sse_uses_image_dimensions_not_output_text() {
|
2026-05-24 17:58:00 +08:00
|
|
|
|
let request = ChatGptWebImageRequest {
|
2026-06-08 13:22:08 +08:00
|
|
|
|
operation: "generate".to_string(),
|
2026-05-24 17:58:00 +08:00
|
|
|
|
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(),
|
2026-05-24 18:15:29 +08:00
|
|
|
|
quality: Some("low".to_string()),
|
|
|
|
|
|
partial_images: 0,
|
2026-05-24 17:58:00 +08:00
|
|
|
|
images: Vec::new(),
|
|
|
|
|
|
};
|
|
|
|
|
|
let image = DownloadedImage {
|
2026-05-24 18:15:29 +08:00
|
|
|
|
b64_json: "iVBORw0KGgoAAAANSUhEUgAA".repeat(64),
|
2026-05-24 17:58:00 +08:00
|
|
|
|
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"],
|
2026-05-24 18:15:29 +08:00
|
|
|
|
json!(gpt_image2_output_tokens(1402, 1122, "low"))
|
2026-05-24 17:58:00 +08:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-16 19:23:23 +08:00
|
|
|
|
#[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")
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-08 13:22:08 +08:00
|
|
|
|
#[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"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-23 17:40:44 +08:00
|
|
|
|
#[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
|
|
|
|
|
|
.headers
|
|
|
|
|
|
.get(EXECUTION_REQUEST_ACCEPT_INVALID_CERTS_HEADER)
|
|
|
|
|
|
.map(String::as_str),
|
|
|
|
|
|
Some("true")
|
|
|
|
|
|
);
|
|
|
|
|
|
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)
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-23 21:28:13 +08:00
|
|
|
|
#[test]
|
2026-05-23 22:14:51 +08:00
|
|
|
|
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)),
|
|
|
|
|
|
]);
|
2026-05-23 21:28:13 +08:00
|
|
|
|
|
2026-05-23 22:14:51 +08:00
|
|
|
|
assert!(apply_chatgpt_web_image_quota_request_delta_to_metadata(
|
|
|
|
|
|
&mut metadata,
|
|
|
|
|
|
None,
|
|
|
|
|
|
1_000,
|
2026-05-24 03:26:30 +08:00
|
|
|
|
None,
|
2026-05-23 22:14:51 +08:00
|
|
|
|
));
|
2026-05-23 21:28:13 +08:00
|
|
|
|
|
|
|
|
|
|
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));
|
2026-05-23 22:14:51 +08:00
|
|
|
|
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,
|
2026-05-24 03:26:30 +08:00
|
|
|
|
None,
|
2026-05-23 22:14:51 +08:00
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
|
|
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));
|
2026-05-23 21:28:13 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 13:58:54 +08:00
|
|
|
|
#[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));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 00:45:09 +08:00
|
|
|
|
#[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,
|
2026-05-24 03:26:30 +08:00
|
|
|
|
None,
|
2026-05-24 00:45:09 +08:00
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 13:58:54 +08:00
|
|
|
|
#[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"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 03:26:30 +08:00
|
|
|
|
#[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")
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-06 02:29:17 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-25 01:56:25 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-06 02:29:17 +08:00
|
|
|
|
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_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() {
|
|
|
|
|
|
assert!(is_generated_web_asset_url(
|
|
|
|
|
|
"https://files.oaiusercontent.com/generated/icon-logo-output.png"
|
|
|
|
|
|
));
|
|
|
|
|
|
assert!(!is_generated_web_asset_url(
|
|
|
|
|
|
"https://openaiassets.blob.core.windows.net/$web/chatgpt/filled-plus-icon.svg"
|
|
|
|
|
|
));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[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());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[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"));
|
2026-05-24 17:58:00 +08:00
|
|
|
|
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());
|
2026-05-24 18:15:29 +08:00
|
|
|
|
assert_eq!(completed["usage"]["output_tokens"], json!(1756));
|
2026-05-24 17:58:00 +08:00
|
|
|
|
assert_eq!(
|
|
|
|
|
|
completed["tool_usage"]["image_gen"]["output_tokens"],
|
2026-05-24 18:15:29 +08:00
|
|
|
|
json!(1756)
|
2026-05-24 17:58:00 +08:00
|
|
|
|
);
|
2026-05-06 02:29:17 +08:00
|
|
|
|
|
|
|
|
|
|
handle.abort();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-25 01:56:25 +08:00
|
|
|
|
#[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
|
2026-07-30 01:03:05 +08:00
|
|
|
|
.expect("executor should preserve the upstream HTTP response")
|
2026-05-25 01:56:25 +08:00
|
|
|
|
.expect("plan should be intercepted");
|
|
|
|
|
|
|
2026-07-30 01:03:05 +08:00
|
|
|
|
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")
|
|
|
|
|
|
);
|
2026-05-25 01:56:25 +08:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-30 01:03:05 +08:00
|
|
|
|
#[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(_)
|
|
|
|
|
|
));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-06 02:29:17 +08:00
|
|
|
|
#[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\""));
|
2026-05-18 01:32:45 +08:00
|
|
|
|
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");
|
2026-05-24 17:58:00 +08:00
|
|
|
|
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),
|
2026-05-24 18:15:29 +08:00
|
|
|
|
Some(1756)
|
2026-05-24 17:58:00 +08:00
|
|
|
|
);
|
2026-05-18 01:32:45 +08:00
|
|
|
|
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")
|
|
|
|
|
|
);
|
2026-05-06 02:29:17 +08:00
|
|
|
|
|
|
|
|
|
|
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");
|
2026-05-17 21:19:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[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");
|
2026-05-06 02:29:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[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\""));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|