Merge branch 'fork/Xbang0222/fix/image-429-cascade-clean'
Browse filesCo-Authored-By: Codex <noreply@openai.com>
# Conflicts:
# services/openai_backend_api.py
- services/config.py +19 -0
- services/openai_backend_api.py +80 -7
- services/protocol/conversation.py +15 -2
- utils/helper.py +42 -1
services/config.py
CHANGED
|
@@ -226,6 +226,23 @@ class ConfigStore:
|
|
| 226 |
except (TypeError, ValueError):
|
| 227 |
return 120
|
| 228 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
@property
|
| 230 |
def image_account_concurrency(self) -> int:
|
| 231 |
try:
|
|
@@ -316,6 +333,8 @@ class ConfigStore:
|
|
| 316 |
data["refresh_account_interval_minute"] = self.refresh_account_interval_minute
|
| 317 |
data["image_retention_days"] = self.image_retention_days
|
| 318 |
data["image_poll_timeout_secs"] = self.image_poll_timeout_secs
|
|
|
|
|
|
|
| 319 |
data["image_account_concurrency"] = self.image_account_concurrency
|
| 320 |
data["auto_remove_invalid_accounts"] = self.auto_remove_invalid_accounts
|
| 321 |
data["auto_remove_rate_limited_accounts"] = self.auto_remove_rate_limited_accounts
|
|
|
|
| 226 |
except (TypeError, ValueError):
|
| 227 |
return 120
|
| 228 |
|
| 229 |
+
@property
|
| 230 |
+
def image_poll_interval_secs(self) -> float:
|
| 231 |
+
try:
|
| 232 |
+
return max(0.5, float(self.data.get("image_poll_interval_secs", 10.0)))
|
| 233 |
+
except (TypeError, ValueError):
|
| 234 |
+
return 10.0
|
| 235 |
+
|
| 236 |
+
@property
|
| 237 |
+
def image_poll_initial_wait_secs(self) -> float:
|
| 238 |
+
"""Image generation upstream takes ~30s; polling immediately wastes requests
|
| 239 |
+
and trips a transient 429. Default 10s gives the conversation document time
|
| 240 |
+
to commit before the first poll."""
|
| 241 |
+
try:
|
| 242 |
+
return max(0.0, float(self.data.get("image_poll_initial_wait_secs", 10.0)))
|
| 243 |
+
except (TypeError, ValueError):
|
| 244 |
+
return 10.0
|
| 245 |
+
|
| 246 |
@property
|
| 247 |
def image_account_concurrency(self) -> int:
|
| 248 |
try:
|
|
|
|
| 333 |
data["refresh_account_interval_minute"] = self.refresh_account_interval_minute
|
| 334 |
data["image_retention_days"] = self.image_retention_days
|
| 335 |
data["image_poll_timeout_secs"] = self.image_poll_timeout_secs
|
| 336 |
+
data["image_poll_interval_secs"] = self.image_poll_interval_secs
|
| 337 |
+
data["image_poll_initial_wait_secs"] = self.image_poll_initial_wait_secs
|
| 338 |
data["image_account_concurrency"] = self.image_account_concurrency
|
| 339 |
data["auto_remove_invalid_accounts"] = self.auto_remove_invalid_accounts
|
| 340 |
data["auto_remove_rate_limited_accounts"] = self.auto_remove_rate_limited_accounts
|
services/openai_backend_api.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
import base64
|
| 2 |
import os
|
|
|
|
| 3 |
import re
|
| 4 |
import time
|
| 5 |
from concurrent.futures import ThreadPoolExecutor
|
|
@@ -14,7 +15,7 @@ from PIL import Image
|
|
| 14 |
from services.account_service import account_service
|
| 15 |
from services.config import config
|
| 16 |
from services.proxy_service import proxy_settings
|
| 17 |
-
from utils.helper import ensure_ok, iter_sse_payloads, new_uuid
|
| 18 |
from utils.log import logger
|
| 19 |
from utils.pow import build_legacy_requirements_token, build_proof_token, parse_pow_resources
|
| 20 |
from utils.turnstile import solve_turnstile_token
|
|
@@ -643,13 +644,76 @@ class OpenAIBackendAPI:
|
|
| 643 |
return sorted(records, key=lambda item: item["create_time"])
|
| 644 |
|
| 645 |
def _poll_image_results(self, conversation_id: str, timeout_secs: float = 120.0) -> tuple[list[str], list[str]]:
|
| 646 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 647 |
start = time.time()
|
| 648 |
attempt = 0
|
| 649 |
-
|
| 650 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 651 |
attempt += 1
|
| 652 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 653 |
file_ids, sediment_ids = [], []
|
| 654 |
for record in self._extract_image_tool_records(conversation):
|
| 655 |
for file_id in record["file_ids"]:
|
|
@@ -670,8 +734,17 @@ class OpenAIBackendAPI:
|
|
| 670 |
return [], sediment_ids
|
| 671 |
logger.debug({"event": "image_poll_wait", "conversation_id": conversation_id,
|
| 672 |
"elapsed_secs": round(time.time() - start, 1)})
|
| 673 |
-
|
| 674 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 675 |
raise ImagePollTimeoutError(
|
| 676 |
f"ChatGPT 生图超时(已等待 {timeout_secs} 秒)。"
|
| 677 |
f"当前超时阈值可在 config.json 中调大 image_poll_timeout_secs,"
|
|
|
|
| 1 |
import base64
|
| 2 |
import os
|
| 3 |
+
import random
|
| 4 |
import re
|
| 5 |
import time
|
| 6 |
from concurrent.futures import ThreadPoolExecutor
|
|
|
|
| 15 |
from services.account_service import account_service
|
| 16 |
from services.config import config
|
| 17 |
from services.proxy_service import proxy_settings
|
| 18 |
+
from utils.helper import UpstreamHTTPError, ensure_ok, iter_sse_payloads, new_uuid
|
| 19 |
from utils.log import logger
|
| 20 |
from utils.pow import build_legacy_requirements_token, build_proof_token, parse_pow_resources
|
| 21 |
from utils.turnstile import solve_turnstile_token
|
|
|
|
| 644 |
return sorted(records, key=lambda item: item["create_time"])
|
| 645 |
|
| 646 |
def _poll_image_results(self, conversation_id: str, timeout_secs: float = 120.0) -> tuple[list[str], list[str]]:
|
| 647 |
+
"""Poll the conversation document until image file ids appear or budget runs out.
|
| 648 |
+
|
| 649 |
+
- Sleeps image_poll_initial_wait_secs first (default 10s, +jitter). ChatGPT
|
| 650 |
+
image generation takes ~30s; polling immediately wastes requests and trips
|
| 651 |
+
a transient 429 the upstream returns within ~200ms of the SSE stream
|
| 652 |
+
closing (the conversation document is not yet committed).
|
| 653 |
+
- Subsequent polls are image_poll_interval_secs apart (default 10s).
|
| 654 |
+
- On upstream 429 / 5xx or network errors, backs off exponentially
|
| 655 |
+
(capped at 16s, +jitter) honoring Retry-After when present.
|
| 656 |
+
- All sleeps stay within timeout_secs; on exhaustion raises ImagePollTimeoutError.
|
| 657 |
+
"""
|
| 658 |
start = time.time()
|
| 659 |
attempt = 0
|
| 660 |
+
interval = float(config.image_poll_interval_secs)
|
| 661 |
+
initial_wait = float(config.image_poll_initial_wait_secs)
|
| 662 |
+
logger.info({
|
| 663 |
+
"event": "image_poll_start",
|
| 664 |
+
"conversation_id": conversation_id,
|
| 665 |
+
"timeout_secs": timeout_secs,
|
| 666 |
+
"initial_wait_secs": initial_wait,
|
| 667 |
+
"interval_secs": interval,
|
| 668 |
+
})
|
| 669 |
+
|
| 670 |
+
def _remaining() -> float:
|
| 671 |
+
return timeout_secs - (time.time() - start)
|
| 672 |
+
|
| 673 |
+
if initial_wait > 0:
|
| 674 |
+
jitter = random.uniform(0, min(2.0, initial_wait * 0.2))
|
| 675 |
+
sleep_for = min(initial_wait + jitter, max(0.0, _remaining()))
|
| 676 |
+
if sleep_for > 0:
|
| 677 |
+
time.sleep(sleep_for)
|
| 678 |
+
|
| 679 |
+
def _retry_sleep(reason: str, status_code: int | None, error: str | None, retry_after: int | None) -> bool:
|
| 680 |
+
# retry_after=0 means "retry immediately" — must not be coerced via falsy check.
|
| 681 |
+
base = retry_after if retry_after is not None else min(2 ** min(attempt, 4), 16)
|
| 682 |
+
backoff = base + random.uniform(0, 0.5)
|
| 683 |
+
remaining = _remaining()
|
| 684 |
+
if remaining <= 0:
|
| 685 |
+
return False
|
| 686 |
+
sleep_for = min(backoff, remaining)
|
| 687 |
+
log_payload: Dict[str, Any] = {
|
| 688 |
+
"event": "image_poll_retry",
|
| 689 |
+
"conversation_id": conversation_id,
|
| 690 |
+
"attempt": attempt,
|
| 691 |
+
"reason": reason,
|
| 692 |
+
"sleep_secs": round(sleep_for, 2),
|
| 693 |
+
}
|
| 694 |
+
if status_code is not None:
|
| 695 |
+
log_payload["status_code"] = status_code
|
| 696 |
+
if error is not None:
|
| 697 |
+
log_payload["error"] = error
|
| 698 |
+
logger.warning(log_payload)
|
| 699 |
+
time.sleep(sleep_for)
|
| 700 |
+
return True
|
| 701 |
+
|
| 702 |
+
while _remaining() > 0:
|
| 703 |
attempt += 1
|
| 704 |
+
try:
|
| 705 |
+
conversation = self._get_conversation(conversation_id)
|
| 706 |
+
except UpstreamHTTPError as exc:
|
| 707 |
+
if exc.status_code in (429, 500, 502, 503, 504):
|
| 708 |
+
if _retry_sleep("upstream_status", exc.status_code, None, exc.retry_after):
|
| 709 |
+
continue
|
| 710 |
+
break
|
| 711 |
+
raise
|
| 712 |
+
except requests.exceptions.RequestException as exc:
|
| 713 |
+
if _retry_sleep("network", None, str(exc), None):
|
| 714 |
+
continue
|
| 715 |
+
break
|
| 716 |
+
|
| 717 |
file_ids, sediment_ids = [], []
|
| 718 |
for record in self._extract_image_tool_records(conversation):
|
| 719 |
for file_id in record["file_ids"]:
|
|
|
|
| 734 |
return [], sediment_ids
|
| 735 |
logger.debug({"event": "image_poll_wait", "conversation_id": conversation_id,
|
| 736 |
"elapsed_secs": round(time.time() - start, 1)})
|
| 737 |
+
wait = min(interval, max(0.0, _remaining()))
|
| 738 |
+
if wait > 0:
|
| 739 |
+
time.sleep(wait)
|
| 740 |
+
logger.info({
|
| 741 |
+
"event": "image_poll_timeout",
|
| 742 |
+
"conversation_id": conversation_id,
|
| 743 |
+
"timeout_secs": timeout_secs,
|
| 744 |
+
"attempts_made": attempt,
|
| 745 |
+
# attempts_made == 0 means the initial_wait consumed the entire budget — no HTTP attempted.
|
| 746 |
+
"initial_wait_exhausted_budget": attempt == 0,
|
| 747 |
+
})
|
| 748 |
raise ImagePollTimeoutError(
|
| 749 |
f"ChatGPT 生图超时(已等待 {timeout_secs} 秒)。"
|
| 750 |
f"当前超时阈值可在 config.json 中调大 image_poll_timeout_secs,"
|
services/protocol/conversation.py
CHANGED
|
@@ -354,7 +354,8 @@ def add_unique(values: list[str], candidates: list[str]) -> None:
|
|
| 354 |
def extract_conversation_ids(payload: str) -> tuple[str, list[str], list[str]]:
|
| 355 |
conversation_match = re.search(r'"conversation_id"\s*:\s*"([^"]+)"', payload)
|
| 356 |
conversation_id = conversation_match.group(1) if conversation_match else ""
|
| 357 |
-
|
|
|
|
| 358 |
sediment_ids = re.findall(r"sediment://([A-Za-z0-9_-]+)", payload)
|
| 359 |
return conversation_id, file_ids, sediment_ids
|
| 360 |
|
|
@@ -373,7 +374,19 @@ def update_conversation_state(state: ConversationState, payload: str, event: dic
|
|
| 373 |
conversation_id, file_ids, sediment_ids = extract_conversation_ids(payload)
|
| 374 |
if conversation_id and not state.conversation_id:
|
| 375 |
state.conversation_id = conversation_id
|
| 376 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 377 |
add_unique(state.file_ids, file_ids)
|
| 378 |
add_unique(state.sediment_ids, sediment_ids)
|
| 379 |
if not isinstance(event, dict):
|
|
|
|
| 354 |
def extract_conversation_ids(payload: str) -> tuple[str, list[str], list[str]]:
|
| 355 |
conversation_match = re.search(r'"conversation_id"\s*:\s*"([^"]+)"', payload)
|
| 356 |
conversation_id = conversation_match.group(1) if conversation_match else ""
|
| 357 |
+
# Negative lookahead excludes "file-service" (URI prefix, not a real id).
|
| 358 |
+
file_ids = re.findall(r"(file[-_](?!service\b)[A-Za-z0-9]+)", payload)
|
| 359 |
sediment_ids = re.findall(r"sediment://([A-Za-z0-9_-]+)", payload)
|
| 360 |
return conversation_id, file_ids, sediment_ids
|
| 361 |
|
|
|
|
| 374 |
conversation_id, file_ids, sediment_ids = extract_conversation_ids(payload)
|
| 375 |
if conversation_id and not state.conversation_id:
|
| 376 |
state.conversation_id = conversation_id
|
| 377 |
+
# Accept file_id / sediment_id when any of:
|
| 378 |
+
# 1) event is a complete image_gen tool message
|
| 379 |
+
# 2) prior server_ste_metadata already flipped tool_invoked True (in an image_gen turn)
|
| 380 |
+
# 3) patch event whose payload references asset_pointer / file-service://
|
| 381 |
+
# User messages (type=conversation.message) never satisfy these, so attacker-controlled
|
| 382 |
+
# substrings in user input cannot inject file ids into state.
|
| 383 |
+
is_patch_event = isinstance(event, dict) and event.get("o") == "patch"
|
| 384 |
+
image_context = (
|
| 385 |
+
(isinstance(event, dict) and is_image_tool_event(event))
|
| 386 |
+
or state.tool_invoked is True
|
| 387 |
+
or (is_patch_event and ("asset_pointer" in payload or "file-service://" in payload))
|
| 388 |
+
)
|
| 389 |
+
if image_context:
|
| 390 |
add_unique(state.file_ids, file_ids)
|
| 391 |
add_unique(state.sediment_ids, sediment_ids)
|
| 392 |
if not isinstance(event, dict):
|
utils/helper.py
CHANGED
|
@@ -102,6 +102,41 @@ def is_image_chat_request(body: dict[str, object]) -> bool:
|
|
| 102 |
return isinstance(modalities, list) and "image" in {str(item or "").strip().lower() for item in modalities}
|
| 103 |
|
| 104 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
def ensure_ok(response: requests.Response, context: str) -> None:
|
| 106 |
if 200 <= response.status_code < 300:
|
| 107 |
return
|
|
@@ -110,7 +145,13 @@ def ensure_ok(response: requests.Response, context: str) -> None:
|
|
| 110 |
body = response.json()
|
| 111 |
except Exception:
|
| 112 |
pass
|
| 113 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
|
| 115 |
|
| 116 |
def sse_json_stream(items) -> Iterator[str]:
|
|
|
|
| 102 |
return isinstance(modalities, list) and "image" in {str(item or "").strip().lower() for item in modalities}
|
| 103 |
|
| 104 |
|
| 105 |
+
_UPSTREAM_BODY_LOG_LIMIT = 500
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
class UpstreamHTTPError(RuntimeError):
|
| 109 |
+
"""Raised when an upstream HTTP call returns a non-2xx status.
|
| 110 |
+
|
| 111 |
+
Carries structured fields (status_code, body, retry_after) so callers can
|
| 112 |
+
branch on status code instead of string-matching on str(exc). The full
|
| 113 |
+
body is preserved on the instance; the formatted message truncates it
|
| 114 |
+
to keep log lines reasonable.
|
| 115 |
+
"""
|
| 116 |
+
|
| 117 |
+
def __init__(
|
| 118 |
+
self,
|
| 119 |
+
context: str,
|
| 120 |
+
status_code: int,
|
| 121 |
+
body: Any,
|
| 122 |
+
retry_after: int | None = None,
|
| 123 |
+
) -> None:
|
| 124 |
+
self.context = context
|
| 125 |
+
self.status_code = status_code
|
| 126 |
+
self.body = body
|
| 127 |
+
self.retry_after = retry_after
|
| 128 |
+
if isinstance(body, (dict, list)):
|
| 129 |
+
try:
|
| 130 |
+
body_str = json.dumps(body, ensure_ascii=False)
|
| 131 |
+
except (TypeError, ValueError):
|
| 132 |
+
body_str = repr(body)
|
| 133 |
+
else:
|
| 134 |
+
body_str = str(body)
|
| 135 |
+
if len(body_str) > _UPSTREAM_BODY_LOG_LIMIT:
|
| 136 |
+
body_str = body_str[:_UPSTREAM_BODY_LOG_LIMIT] + "…[truncated]"
|
| 137 |
+
super().__init__(f"{context} failed: status={status_code}, body={body_str}")
|
| 138 |
+
|
| 139 |
+
|
| 140 |
def ensure_ok(response: requests.Response, context: str) -> None:
|
| 141 |
if 200 <= response.status_code < 300:
|
| 142 |
return
|
|
|
|
| 145 |
body = response.json()
|
| 146 |
except Exception:
|
| 147 |
pass
|
| 148 |
+
retry_after_header = response.headers.get("Retry-After") if hasattr(response, "headers") else None
|
| 149 |
+
retry_after: int | None = None
|
| 150 |
+
if retry_after_header is not None:
|
| 151 |
+
ra_str = str(retry_after_header).strip()
|
| 152 |
+
if ra_str.isdigit():
|
| 153 |
+
retry_after = int(ra_str)
|
| 154 |
+
raise UpstreamHTTPError(context, response.status_code, body, retry_after=retry_after)
|
| 155 |
|
| 156 |
|
| 157 |
def sse_json_stream(items) -> Iterator[str]:
|