HouYunFei Codex commited on
Commit
e4c42bb
·
2 Parent(s): fb7592d3ae55b5

Merge branch 'fork/Xbang0222/fix/image-429-cascade-clean'

Browse files

Co-Authored-By: Codex <noreply@openai.com>

# Conflicts:
# services/openai_backend_api.py

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
- """轮询 conversation,直到拿到图片文件 id 或超时。"""
 
 
 
 
 
 
 
 
 
 
647
  start = time.time()
648
  attempt = 0
649
- logger.info({"event": "image_poll_start", "conversation_id": conversation_id, "timeout_secs": timeout_secs})
650
- while time.time() - start < timeout_secs:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
651
  attempt += 1
652
- conversation = self._get_conversation(conversation_id)
 
 
 
 
 
 
 
 
 
 
 
 
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
- time.sleep(4)
674
- logger.info({"event": "image_poll_timeout", "conversation_id": conversation_id, "timeout_secs": timeout_secs})
 
 
 
 
 
 
 
 
 
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
- file_ids = re.findall(r"(file[-_][A-Za-z0-9]+)", payload)
 
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
- if isinstance(event, dict) and is_image_tool_event(event):
 
 
 
 
 
 
 
 
 
 
 
 
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
- raise RuntimeError(f"{context} failed: status={response.status_code}, body={body}")
 
 
 
 
 
 
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]: