thynome commited on
Commit
1861a01
·
1 Parent(s): 47c35ad

Fix multi-image ChatGPT image results

Browse files
services/openai_backend_api.py CHANGED
@@ -53,6 +53,10 @@ SEARCH_POLL_INTERVAL_SECS = 3.0
53
  SEARCH_DONE_STATUS = {"finished_successfully", "finished_partial_completion"}
54
  SEARCH_CONVERSATION_ID_RE = re.compile(r'"conversation_id"\s*:\s*"([^"]+)"')
55
  SEARCH_URL_RE = re.compile(r"https?://[^\s\"'<>))\]}]+")
 
 
 
 
56
  CODEX_RESPONSES_INSTRUCTIONS = (
57
  "Use the image_generation tool to create exactly one image for the user's request. "
58
  "Return the generated image result."
@@ -1091,39 +1095,78 @@ class OpenAIBackendAPI:
1091
  def _clean_search_url(self, value: Any) -> str:
1092
  return str(value or "").strip().rstrip(".,;,。;")
1093
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1094
  def _extract_image_tool_records(self, data: Dict[str, Any]) -> list[Dict[str, Any]]:
1095
  """从 conversation 明细里提取图片工具输出记录。"""
1096
  mapping = data.get("mapping") or {}
1097
- file_pat = re.compile(r"file-service://([A-Za-z0-9_-]+)")
1098
- sed_pat = re.compile(r"sediment://([A-Za-z0-9_-]+)")
1099
  records = []
1100
  for message_id, node in mapping.items():
1101
  message = (node or {}).get("message") or {}
1102
  author = message.get("author") or {}
1103
  metadata = message.get("metadata") or {}
1104
  content = message.get("content") or {}
1105
- if author.get("role") != "tool":
 
1106
  continue
1107
- if content.get("content_type") != "multimodal_text":
 
 
1108
  continue
1109
- file_ids, sediment_ids = [], []
1110
- for part in content.get("parts") or []:
1111
- text = (part.get("asset_pointer") or "") if isinstance(part, dict) else (
1112
- part if isinstance(part, str) else "")
1113
- for hit in file_pat.findall(text):
1114
- if hit not in file_ids:
1115
- file_ids.append(hit)
1116
- for hit in sed_pat.findall(text):
1117
- if hit not in sediment_ids:
1118
- sediment_ids.append(hit)
1119
- if metadata.get("async_task_type") != "image_gen" and not file_ids and not sediment_ids:
1120
  continue
1121
  records.append(
1122
  {"message_id": message_id, "create_time": message.get("create_time") or 0, "file_ids": file_ids,
1123
  "sediment_ids": sediment_ids})
1124
  return sorted(records, key=lambda item: item["create_time"])
1125
 
1126
- def _poll_image_results(self, conversation_id: str, timeout_secs: float = 120.0) -> tuple[list[str], list[str]]:
 
 
 
 
 
 
1127
  """Poll the conversation document until image file ids appear or budget runs out.
1128
 
1129
  - Sleeps image_poll_initial_wait_secs first (default 10s, +jitter). ChatGPT
@@ -1139,18 +1182,32 @@ class OpenAIBackendAPI:
1139
  attempt = 0
1140
  interval = float(config.image_poll_interval_secs)
1141
  initial_wait = float(config.image_poll_initial_wait_secs)
 
 
 
 
 
 
 
 
1142
  logger.info({
1143
  "event": "image_poll_start",
1144
  "conversation_id": conversation_id,
1145
  "timeout_secs": timeout_secs,
1146
  "initial_wait_secs": initial_wait,
1147
  "interval_secs": interval,
 
 
1148
  })
1149
 
1150
  def _remaining() -> float:
1151
  return timeout_secs - (time.time() - start)
1152
 
1153
- if initial_wait > 0:
 
 
 
 
1154
  jitter = random.uniform(0, min(2.0, initial_wait * 0.2))
1155
  sleep_for = min(initial_wait + jitter, max(0.0, _remaining()))
1156
  if sleep_for > 0:
@@ -1194,7 +1251,6 @@ class OpenAIBackendAPI:
1194
  continue
1195
  break
1196
 
1197
- file_ids, sediment_ids = [], []
1198
  for record in self._extract_image_tool_records(conversation):
1199
  for file_id in record["file_ids"]:
1200
  if file_id not in file_ids:
@@ -1204,14 +1260,20 @@ class OpenAIBackendAPI:
1204
  sediment_ids.append(sediment_id)
1205
  logger.debug({"event": "image_poll_check", "conversation_id": conversation_id, "attempt": attempt,
1206
  "file_ids": file_ids, "sediment_ids": sediment_ids})
1207
- if file_ids:
1208
- logger.info({"event": "image_poll_hit", "conversation_id": conversation_id, "file_ids": file_ids,
1209
- "sediment_ids": sediment_ids})
 
 
 
 
 
 
 
 
 
 
1210
  return file_ids, sediment_ids
1211
- if sediment_ids:
1212
- logger.info({"event": "image_poll_hit", "conversation_id": conversation_id, "file_ids": [],
1213
- "sediment_ids": sediment_ids})
1214
- return [], sediment_ids
1215
  logger.debug({"event": "image_poll_wait", "conversation_id": conversation_id,
1216
  "elapsed_secs": round(time.time() - start, 1)})
1217
  wait = min(interval, max(0.0, _remaining()))
@@ -1274,7 +1336,8 @@ class OpenAIBackendAPI:
1274
  })
1275
  continue
1276
  if url:
1277
- urls.append(url)
 
1278
  else:
1279
  logger.debug({
1280
  "event": "image_download_url_empty",
@@ -1282,7 +1345,7 @@ class OpenAIBackendAPI:
1282
  "conversation_id": conversation_id,
1283
  "id": file_id,
1284
  })
1285
- if urls or not conversation_id:
1286
  logger.debug({
1287
  "event": "image_urls_resolved",
1288
  "conversation_id": conversation_id,
@@ -1304,7 +1367,8 @@ class OpenAIBackendAPI:
1304
  })
1305
  continue
1306
  if url:
1307
- urls.append(url)
 
1308
  else:
1309
  logger.debug({
1310
  "event": "image_download_url_empty",
@@ -1330,12 +1394,42 @@ class OpenAIBackendAPI:
1330
  ) -> list[str]:
1331
  file_ids = [item for item in file_ids if item != "file_upload"]
1332
  sediment_ids = list(sediment_ids)
1333
- if poll and conversation_id and not file_ids and not sediment_ids:
1334
- logger.info({"event": "image_resolve_poll_needed", "conversation_id": conversation_id})
1335
- polled_file_ids, polled_sediment_ids = self._poll_image_results(conversation_id,
1336
- config.image_poll_timeout_secs)
1337
- file_ids.extend(item for item in polled_file_ids if item and item not in file_ids)
1338
- sediment_ids.extend(item for item in polled_sediment_ids if item and item not in sediment_ids)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1339
  return self._resolve_image_urls(conversation_id, file_ids, sediment_ids)
1340
 
1341
  def download_image_bytes(self, urls: list[str]) -> list[bytes]:
@@ -1343,7 +1437,8 @@ class OpenAIBackendAPI:
1343
  for url in urls:
1344
  response = self.session.get(url, timeout=120)
1345
  ensure_ok(response, "image_download")
1346
- images.append(response.content)
 
1347
  return images
1348
 
1349
  def stream_conversation(
 
53
  SEARCH_DONE_STATUS = {"finished_successfully", "finished_partial_completion"}
54
  SEARCH_CONVERSATION_ID_RE = re.compile(r'"conversation_id"\s*:\s*"([^"]+)"')
55
  SEARCH_URL_RE = re.compile(r"https?://[^\s\"'<>))\]}]+")
56
+ FILE_SERVICE_ID_RE = re.compile(r"file-service://([A-Za-z0-9_-]+)")
57
+ FILE_ID_RE = re.compile(r"\b(file[-_](?!service\b)[A-Za-z0-9_-]+)\b")
58
+ SEDIMENT_ID_RE = re.compile(r"sediment://([A-Za-z0-9_-]+)")
59
+ IMAGE_POLL_SETTLE_SECS = 2.0
60
  CODEX_RESPONSES_INSTRUCTIONS = (
61
  "Use the image_generation tool to create exactly one image for the user's request. "
62
  "Return the generated image result."
 
1095
  def _clean_search_url(self, value: Any) -> str:
1096
  return str(value or "").strip().rstrip(".,;,。;")
1097
 
1098
+ @staticmethod
1099
+ def _add_unique(values: list[str], candidates: list[str]) -> None:
1100
+ for candidate in candidates:
1101
+ if candidate and candidate not in values:
1102
+ values.append(candidate)
1103
+
1104
+ @classmethod
1105
+ def _extract_image_reference_ids(cls, payload: Any) -> tuple[list[str], list[str]]:
1106
+ file_ids: list[str] = []
1107
+ sediment_ids: list[str] = []
1108
+
1109
+ def walk(value: Any) -> None:
1110
+ if isinstance(value, str):
1111
+ cls._add_unique(file_ids, FILE_SERVICE_ID_RE.findall(value))
1112
+ cls._add_unique(file_ids, FILE_ID_RE.findall(value))
1113
+ cls._add_unique(sediment_ids, SEDIMENT_ID_RE.findall(value))
1114
+ return
1115
+ if isinstance(value, dict):
1116
+ for item in value.values():
1117
+ walk(item)
1118
+ return
1119
+ if isinstance(value, list):
1120
+ for item in value:
1121
+ walk(item)
1122
+
1123
+ walk(payload)
1124
+ return file_ids, sediment_ids
1125
+
1126
+ @classmethod
1127
+ def _has_image_asset_pointer(cls, payload: Any) -> bool:
1128
+ if isinstance(payload, dict):
1129
+ if str(payload.get("content_type") or "") == "image_asset_pointer":
1130
+ return True
1131
+ asset_pointer = str(payload.get("asset_pointer") or "")
1132
+ if asset_pointer.startswith(("file-service://", "sediment://")):
1133
+ return True
1134
+ return any(cls._has_image_asset_pointer(item) for item in payload.values())
1135
+ if isinstance(payload, list):
1136
+ return any(cls._has_image_asset_pointer(item) for item in payload)
1137
+ return False
1138
+
1139
  def _extract_image_tool_records(self, data: Dict[str, Any]) -> list[Dict[str, Any]]:
1140
  """从 conversation 明细里提取图片工具输出记录。"""
1141
  mapping = data.get("mapping") or {}
 
 
1142
  records = []
1143
  for message_id, node in mapping.items():
1144
  message = (node or {}).get("message") or {}
1145
  author = message.get("author") or {}
1146
  metadata = message.get("metadata") or {}
1147
  content = message.get("content") or {}
1148
+ role = str(author.get("role") or "").strip().lower()
1149
+ if role not in {"tool", "assistant"}:
1150
  continue
1151
+ is_image_gen = metadata.get("async_task_type") == "image_gen"
1152
+ has_asset_pointer = self._has_image_asset_pointer(content) or self._has_image_asset_pointer(metadata)
1153
+ if role == "assistant" and not (is_image_gen or has_asset_pointer):
1154
  continue
1155
+ file_ids, sediment_ids = self._extract_image_reference_ids({"content": content, "metadata": metadata})
1156
+ if not is_image_gen and not has_asset_pointer and not file_ids and not sediment_ids:
 
 
 
 
 
 
 
 
 
1157
  continue
1158
  records.append(
1159
  {"message_id": message_id, "create_time": message.get("create_time") or 0, "file_ids": file_ids,
1160
  "sediment_ids": sediment_ids})
1161
  return sorted(records, key=lambda item: item["create_time"])
1162
 
1163
+ def _poll_image_results(
1164
+ self,
1165
+ conversation_id: str,
1166
+ timeout_secs: float = 120.0,
1167
+ initial_file_ids: list[str] | None = None,
1168
+ initial_sediment_ids: list[str] | None = None,
1169
+ ) -> tuple[list[str], list[str]]:
1170
  """Poll the conversation document until image file ids appear or budget runs out.
1171
 
1172
  - Sleeps image_poll_initial_wait_secs first (default 10s, +jitter). ChatGPT
 
1182
  attempt = 0
1183
  interval = float(config.image_poll_interval_secs)
1184
  initial_wait = float(config.image_poll_initial_wait_secs)
1185
+ file_ids: list[str] = []
1186
+ sediment_ids: list[str] = []
1187
+ self._add_unique(file_ids, initial_file_ids or [])
1188
+ self._add_unique(sediment_ids, initial_sediment_ids or [])
1189
+ has_initial_ids = bool(file_ids or sediment_ids)
1190
+ last_hit_key: tuple[tuple[str, ...], tuple[str, ...]] | None = (
1191
+ (tuple(file_ids), tuple(sediment_ids)) if has_initial_ids else None
1192
+ )
1193
  logger.info({
1194
  "event": "image_poll_start",
1195
  "conversation_id": conversation_id,
1196
  "timeout_secs": timeout_secs,
1197
  "initial_wait_secs": initial_wait,
1198
  "interval_secs": interval,
1199
+ "initial_file_ids": file_ids,
1200
+ "initial_sediment_ids": sediment_ids,
1201
  })
1202
 
1203
  def _remaining() -> float:
1204
  return timeout_secs - (time.time() - start)
1205
 
1206
+ if has_initial_ids:
1207
+ settle_for = min(IMAGE_POLL_SETTLE_SECS, max(0.0, _remaining()))
1208
+ if settle_for > 0:
1209
+ time.sleep(settle_for)
1210
+ elif initial_wait > 0:
1211
  jitter = random.uniform(0, min(2.0, initial_wait * 0.2))
1212
  sleep_for = min(initial_wait + jitter, max(0.0, _remaining()))
1213
  if sleep_for > 0:
 
1251
  continue
1252
  break
1253
 
 
1254
  for record in self._extract_image_tool_records(conversation):
1255
  for file_id in record["file_ids"]:
1256
  if file_id not in file_ids:
 
1260
  sediment_ids.append(sediment_id)
1261
  logger.debug({"event": "image_poll_check", "conversation_id": conversation_id, "attempt": attempt,
1262
  "file_ids": file_ids, "sediment_ids": sediment_ids})
1263
+ if file_ids or sediment_ids:
1264
+ hit_key = (tuple(file_ids), tuple(sediment_ids))
1265
+ if last_hit_key == hit_key:
1266
+ logger.info({"event": "image_poll_hit", "conversation_id": conversation_id, "file_ids": file_ids,
1267
+ "sediment_ids": sediment_ids})
1268
+ return file_ids, sediment_ids
1269
+ last_hit_key = hit_key
1270
+ logger.info({"event": "image_poll_hit_pending_settle", "conversation_id": conversation_id,
1271
+ "file_ids": file_ids, "sediment_ids": sediment_ids})
1272
+ wait = min(IMAGE_POLL_SETTLE_SECS, max(0.0, _remaining()))
1273
+ if wait > 0:
1274
+ time.sleep(wait)
1275
+ continue
1276
  return file_ids, sediment_ids
 
 
 
 
1277
  logger.debug({"event": "image_poll_wait", "conversation_id": conversation_id,
1278
  "elapsed_secs": round(time.time() - start, 1)})
1279
  wait = min(interval, max(0.0, _remaining()))
 
1336
  })
1337
  continue
1338
  if url:
1339
+ if url not in urls:
1340
+ urls.append(url)
1341
  else:
1342
  logger.debug({
1343
  "event": "image_download_url_empty",
 
1345
  "conversation_id": conversation_id,
1346
  "id": file_id,
1347
  })
1348
+ if not conversation_id or not sediment_ids:
1349
  logger.debug({
1350
  "event": "image_urls_resolved",
1351
  "conversation_id": conversation_id,
 
1367
  })
1368
  continue
1369
  if url:
1370
+ if url not in urls:
1371
+ urls.append(url)
1372
  else:
1373
  logger.debug({
1374
  "event": "image_download_url_empty",
 
1394
  ) -> list[str]:
1395
  file_ids = [item for item in file_ids if item != "file_upload"]
1396
  sediment_ids = list(sediment_ids)
1397
+ if poll and conversation_id:
1398
+ logger.info({
1399
+ "event": "image_resolve_poll_needed",
1400
+ "conversation_id": conversation_id,
1401
+ "initial_file_ids": file_ids,
1402
+ "initial_sediment_ids": sediment_ids,
1403
+ })
1404
+ try:
1405
+ polled_file_ids, polled_sediment_ids = self._poll_image_results(
1406
+ conversation_id,
1407
+ config.image_poll_timeout_secs,
1408
+ file_ids,
1409
+ sediment_ids,
1410
+ )
1411
+ except ImagePollTimeoutError:
1412
+ if not file_ids and not sediment_ids:
1413
+ raise
1414
+ logger.warning({
1415
+ "event": "image_resolve_poll_partial_timeout",
1416
+ "conversation_id": conversation_id,
1417
+ "file_ids": file_ids,
1418
+ "sediment_ids": sediment_ids,
1419
+ })
1420
+ except Exception as exc:
1421
+ if not file_ids and not sediment_ids:
1422
+ raise
1423
+ logger.warning({
1424
+ "event": "image_resolve_poll_partial_error",
1425
+ "conversation_id": conversation_id,
1426
+ "file_ids": file_ids,
1427
+ "sediment_ids": sediment_ids,
1428
+ "error": repr(exc),
1429
+ })
1430
+ else:
1431
+ file_ids.extend(item for item in polled_file_ids if item and item not in file_ids)
1432
+ sediment_ids.extend(item for item in polled_sediment_ids if item and item not in sediment_ids)
1433
  return self._resolve_image_urls(conversation_id, file_ids, sediment_ids)
1434
 
1435
  def download_image_bytes(self, urls: list[str]) -> list[bytes]:
 
1437
  for url in urls:
1438
  response = self.session.get(url, timeout=120)
1439
  ensure_ok(response, "image_download")
1440
+ if response.content not in images:
1441
+ images.append(response.content)
1442
  return images
1443
 
1444
  def stream_conversation(
services/protocol/conversation.py CHANGED
@@ -404,12 +404,19 @@ def add_unique(values: list[str], candidates: list[str]) -> None:
404
  values.append(candidate)
405
 
406
 
 
 
 
 
 
407
  def extract_conversation_ids(payload: str) -> tuple[str, list[str], list[str]]:
408
  conversation_match = re.search(r'"conversation_id"\s*:\s*"([^"]+)"', payload)
409
  conversation_id = conversation_match.group(1) if conversation_match else ""
 
410
  # Negative lookahead excludes "file-service" (URI prefix, not a real id).
411
- file_ids = re.findall(r"(file[-_](?!service\b)[A-Za-z0-9]+)", payload)
412
- sediment_ids = re.findall(r"sediment://([A-Za-z0-9_-]+)", payload)
 
413
  return conversation_id, file_ids, sediment_ids
414
 
415
 
 
404
  values.append(candidate)
405
 
406
 
407
+ FILE_SERVICE_ID_RE = re.compile(r"file-service://([A-Za-z0-9_-]+)")
408
+ FILE_ID_RE = re.compile(r"\b(file[-_](?!service\b)[A-Za-z0-9_-]+)\b")
409
+ SEDIMENT_ID_RE = re.compile(r"sediment://([A-Za-z0-9_-]+)")
410
+
411
+
412
  def extract_conversation_ids(payload: str) -> tuple[str, list[str], list[str]]:
413
  conversation_match = re.search(r'"conversation_id"\s*:\s*"([^"]+)"', payload)
414
  conversation_id = conversation_match.group(1) if conversation_match else ""
415
+ file_ids: list[str] = []
416
  # Negative lookahead excludes "file-service" (URI prefix, not a real id).
417
+ add_unique(file_ids, FILE_SERVICE_ID_RE.findall(payload))
418
+ add_unique(file_ids, FILE_ID_RE.findall(payload))
419
+ sediment_ids = SEDIMENT_ID_RE.findall(payload)
420
  return conversation_id, file_ids, sediment_ids
421
 
422
 
services/protocol/openai_v1_response.py CHANGED
@@ -294,14 +294,14 @@ def stream_image_response(
294
  continue
295
  items = image_output_items(prompt, output.data)
296
  if items:
297
- item = items[0]
298
  usage = image_usage(
299
  input_text_tokens=count_text_tokens(prompt, model),
300
  input_image_tokens=input_image_tokens,
301
  output_tokens=count_image_output_items_tokens(output.data, size, quality),
302
  )
303
- yield {"type": "response.output_item.done", "output_index": 0, "item": item}
304
- yield response_completed(response_id, model, created, [item], usage)
 
305
  return
306
  raise RuntimeError("image generation failed")
307
 
 
294
  continue
295
  items = image_output_items(prompt, output.data)
296
  if items:
 
297
  usage = image_usage(
298
  input_text_tokens=count_text_tokens(prompt, model),
299
  input_image_tokens=input_image_tokens,
300
  output_tokens=count_image_output_items_tokens(output.data, size, quality),
301
  )
302
+ for output_index, item in enumerate(items):
303
+ yield {"type": "response.output_item.done", "output_index": output_index, "item": item}
304
+ yield response_completed(response_id, model, created, items, usage)
305
  return
306
  raise RuntimeError("image generation failed")
307
 
test/test_multi_image_results.py ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import unittest
5
+ from unittest import mock
6
+
7
+ from services.config import config
8
+ from services.openai_backend_api import OpenAIBackendAPI
9
+ from services.protocol.conversation import ImageOutput, extract_conversation_ids
10
+ from services.protocol.openai_v1_response import stream_image_response
11
+
12
+
13
+ def _conversation(file_ids: list[str], sediment_ids: list[str] | None = None) -> dict:
14
+ parts: list[object] = [
15
+ {"content_type": "image_asset_pointer", "asset_pointer": f"file-service://{file_id}"}
16
+ for file_id in file_ids
17
+ ]
18
+ parts.extend(f"sediment://{sediment_id}" for sediment_id in (sediment_ids or []))
19
+ return {
20
+ "mapping": {
21
+ "tool": {
22
+ "message": {
23
+ "author": {"role": "tool"},
24
+ "create_time": 1,
25
+ "metadata": {"async_task_type": "image_gen"},
26
+ "content": {"content_type": "multimodal_text", "parts": parts},
27
+ }
28
+ }
29
+ }
30
+ }
31
+
32
+
33
+ class FakeBackend(OpenAIBackendAPI):
34
+ def __init__(self, conversations: list[dict] | None = None) -> None:
35
+ self.conversations = conversations or []
36
+ self.calls = 0
37
+ self.file_urls: dict[str, str] = {}
38
+ self.sediment_urls: dict[str, str] = {}
39
+
40
+ def _get_conversation(self, conversation_id: str) -> dict:
41
+ self.calls += 1
42
+ index = min(self.calls - 1, len(self.conversations) - 1)
43
+ return self.conversations[index]
44
+
45
+ def _get_file_download_url(self, file_id: str) -> str:
46
+ return self.file_urls.get(file_id, "")
47
+
48
+ def _get_attachment_download_url(self, conversation_id: str, attachment_id: str) -> str:
49
+ return self.sediment_urls.get(attachment_id, "")
50
+
51
+
52
+ class MultiImageResultTests(unittest.TestCase):
53
+ def test_stream_id_extractor_keeps_full_file_ids(self) -> None:
54
+ payload = (
55
+ '{"conversation_id":"conv-1"} '
56
+ 'file-service://file-first_123-extra sediment://sed-second_456-extra'
57
+ )
58
+
59
+ conversation_id, file_ids, sediment_ids = extract_conversation_ids(payload)
60
+
61
+ self.assertEqual(conversation_id, "conv-1")
62
+ self.assertEqual(file_ids, ["file-first_123-extra"])
63
+ self.assertEqual(sediment_ids, ["sed-second_456-extra"])
64
+
65
+ def test_conversation_record_extractor_finds_all_generated_assets(self) -> None:
66
+ backend = FakeBackend()
67
+ conversation = {
68
+ "mapping": {
69
+ "user": {
70
+ "message": {
71
+ "author": {"role": "user"},
72
+ "content": {"parts": ["file-service://file-user-input"]},
73
+ }
74
+ },
75
+ "tool": {
76
+ "message": {
77
+ "author": {"role": "tool"},
78
+ "create_time": 1,
79
+ "metadata": {
80
+ "async_task_type": "image_gen",
81
+ "nested": {"asset": "file-service://file-second"},
82
+ },
83
+ "content": {
84
+ "content_type": "text",
85
+ "parts": [
86
+ {"content_type": "image_asset_pointer", "asset_pointer": "file-service://file-first"},
87
+ "sediment://sed-first",
88
+ ],
89
+ },
90
+ }
91
+ },
92
+ "assistant": {
93
+ "message": {
94
+ "author": {"role": "assistant"},
95
+ "create_time": 2,
96
+ "metadata": {},
97
+ "content": {
98
+ "parts": [
99
+ {"content_type": "image_asset_pointer", "asset_pointer": "file-service://file-third"}
100
+ ]
101
+ },
102
+ }
103
+ },
104
+ }
105
+ }
106
+
107
+ records = backend._extract_image_tool_records(conversation)
108
+ file_ids = [file_id for record in records for file_id in record["file_ids"]]
109
+ sediment_ids = [sediment_id for record in records for sediment_id in record["sediment_ids"]]
110
+
111
+ self.assertEqual(file_ids, ["file-first", "file-second", "file-third"])
112
+ self.assertEqual(sediment_ids, ["sed-first"])
113
+
114
+ def test_poll_waits_for_generated_asset_ids_to_settle(self) -> None:
115
+ backend = FakeBackend([
116
+ _conversation(["file-one"]),
117
+ _conversation(["file-one", "file-two"], ["sed-one"]),
118
+ _conversation(["file-one", "file-two"], ["sed-one"]),
119
+ ])
120
+
121
+ with (
122
+ mock.patch.dict(config.data, {"image_poll_initial_wait_secs": 0, "image_poll_interval_secs": 0.5}),
123
+ mock.patch("services.openai_backend_api.time.sleep", lambda _seconds: None),
124
+ ):
125
+ file_ids, sediment_ids = backend._poll_image_results("conv-1", timeout_secs=10)
126
+
127
+ self.assertEqual(file_ids, ["file-one", "file-two"])
128
+ self.assertEqual(sediment_ids, ["sed-one"])
129
+ self.assertEqual(backend.calls, 3)
130
+
131
+ def test_resolver_uses_file_and_sediment_urls(self) -> None:
132
+ backend = FakeBackend()
133
+ backend.file_urls = {"file-one": "https://files.test/one.png"}
134
+ backend.sediment_urls = {
135
+ "sed-one": "https://attachments.test/one.png",
136
+ "sed-two": "https://attachments.test/two.png",
137
+ }
138
+
139
+ urls = backend._resolve_image_urls("conv-1", ["file-one"], ["sed-one", "sed-two"])
140
+
141
+ self.assertEqual(urls, [
142
+ "https://files.test/one.png",
143
+ "https://attachments.test/one.png",
144
+ "https://attachments.test/two.png",
145
+ ])
146
+
147
+ def test_resolver_keeps_stream_ids_when_poll_extension_fails(self) -> None:
148
+ backend = FakeBackend()
149
+ backend.file_urls = {"file-one": "https://files.test/one.png"}
150
+ backend._get_conversation = mock.Mock(side_effect=RuntimeError("poll failed"))
151
+
152
+ with mock.patch("services.openai_backend_api.time.sleep", lambda _seconds: None):
153
+ urls = backend.resolve_conversation_image_urls("conv-1", ["file-one"], [], poll=True)
154
+
155
+ self.assertEqual(urls, ["https://files.test/one.png"])
156
+
157
+ def test_responses_stream_emits_all_image_output_items(self) -> None:
158
+ first = base64.b64encode(b"first").decode("ascii")
159
+ second = base64.b64encode(b"second").decode("ascii")
160
+ events = list(stream_image_response(
161
+ [ImageOutput(
162
+ kind="result",
163
+ model="gpt-image-2",
164
+ index=1,
165
+ total=1,
166
+ data=[{"b64_json": first}, {"b64_json": second}],
167
+ )],
168
+ "draw two options",
169
+ "gpt-image-2",
170
+ ))
171
+
172
+ done_events = [event for event in events if event.get("type") == "response.output_item.done"]
173
+ completed = next(event["response"] for event in events if event.get("type") == "response.completed")
174
+
175
+ self.assertEqual([event["output_index"] for event in done_events], [0, 1])
176
+ self.assertEqual([item["result"] for item in completed["output"]], [first, second])
177
+
178
+
179
+ if __name__ == "__main__":
180
+ unittest.main()