kunkun commited on
Commit
4d5cc3f
·
2 Parent(s): c55e7eb1861a01

Merge pull request #212 from thynomex/codex/fix-issue-208-multi-image

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."
@@ -1099,39 +1103,78 @@ class OpenAIBackendAPI:
1099
  def _clean_search_url(self, value: Any) -> str:
1100
  return str(value or "").strip().rstrip(".,;,。;")
1101
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1102
  def _extract_image_tool_records(self, data: Dict[str, Any]) -> list[Dict[str, Any]]:
1103
  """从 conversation 明细里提取图片工具输出记录。"""
1104
  mapping = data.get("mapping") or {}
1105
- file_pat = re.compile(r"file-service://([A-Za-z0-9_-]+)")
1106
- sed_pat = re.compile(r"sediment://([A-Za-z0-9_-]+)")
1107
  records = []
1108
  for message_id, node in mapping.items():
1109
  message = (node or {}).get("message") or {}
1110
  author = message.get("author") or {}
1111
  metadata = message.get("metadata") or {}
1112
  content = message.get("content") or {}
1113
- if author.get("role") != "tool":
 
1114
  continue
1115
- if content.get("content_type") != "multimodal_text":
 
 
1116
  continue
1117
- file_ids, sediment_ids = [], []
1118
- for part in content.get("parts") or []:
1119
- text = (part.get("asset_pointer") or "") if isinstance(part, dict) else (
1120
- part if isinstance(part, str) else "")
1121
- for hit in file_pat.findall(text):
1122
- if hit not in file_ids:
1123
- file_ids.append(hit)
1124
- for hit in sed_pat.findall(text):
1125
- if hit not in sediment_ids:
1126
- sediment_ids.append(hit)
1127
- if metadata.get("async_task_type") != "image_gen" and not file_ids and not sediment_ids:
1128
  continue
1129
  records.append(
1130
  {"message_id": message_id, "create_time": message.get("create_time") or 0, "file_ids": file_ids,
1131
  "sediment_ids": sediment_ids})
1132
  return sorted(records, key=lambda item: item["create_time"])
1133
 
1134
- def _poll_image_results(self, conversation_id: str, timeout_secs: float = 120.0) -> tuple[list[str], list[str]]:
 
 
 
 
 
 
1135
  """Poll the conversation document until image file ids appear or budget runs out.
1136
 
1137
  - Sleeps image_poll_initial_wait_secs first (default 10s, +jitter). ChatGPT
@@ -1147,18 +1190,32 @@ class OpenAIBackendAPI:
1147
  attempt = 0
1148
  interval = float(config.image_poll_interval_secs)
1149
  initial_wait = float(config.image_poll_initial_wait_secs)
 
 
 
 
 
 
 
 
1150
  logger.info({
1151
  "event": "image_poll_start",
1152
  "conversation_id": conversation_id,
1153
  "timeout_secs": timeout_secs,
1154
  "initial_wait_secs": initial_wait,
1155
  "interval_secs": interval,
 
 
1156
  })
1157
 
1158
  def _remaining() -> float:
1159
  return timeout_secs - (time.time() - start)
1160
 
1161
- if initial_wait > 0:
 
 
 
 
1162
  jitter = random.uniform(0, min(2.0, initial_wait * 0.2))
1163
  sleep_for = min(initial_wait + jitter, max(0.0, _remaining()))
1164
  if sleep_for > 0:
@@ -1202,7 +1259,6 @@ class OpenAIBackendAPI:
1202
  continue
1203
  break
1204
 
1205
- file_ids, sediment_ids = [], []
1206
  for record in self._extract_image_tool_records(conversation):
1207
  for file_id in record["file_ids"]:
1208
  if file_id not in file_ids:
@@ -1212,14 +1268,20 @@ class OpenAIBackendAPI:
1212
  sediment_ids.append(sediment_id)
1213
  logger.debug({"event": "image_poll_check", "conversation_id": conversation_id, "attempt": attempt,
1214
  "file_ids": file_ids, "sediment_ids": sediment_ids})
1215
- if file_ids:
1216
- logger.info({"event": "image_poll_hit", "conversation_id": conversation_id, "file_ids": file_ids,
1217
- "sediment_ids": sediment_ids})
 
 
 
 
 
 
 
 
 
 
1218
  return file_ids, sediment_ids
1219
- if sediment_ids:
1220
- logger.info({"event": "image_poll_hit", "conversation_id": conversation_id, "file_ids": [],
1221
- "sediment_ids": sediment_ids})
1222
- return [], sediment_ids
1223
  logger.debug({"event": "image_poll_wait", "conversation_id": conversation_id,
1224
  "elapsed_secs": round(time.time() - start, 1)})
1225
  wait = min(interval, max(0.0, _remaining()))
@@ -1282,7 +1344,8 @@ class OpenAIBackendAPI:
1282
  })
1283
  continue
1284
  if url:
1285
- urls.append(url)
 
1286
  else:
1287
  logger.debug({
1288
  "event": "image_download_url_empty",
@@ -1290,7 +1353,7 @@ class OpenAIBackendAPI:
1290
  "conversation_id": conversation_id,
1291
  "id": file_id,
1292
  })
1293
- if urls or not conversation_id:
1294
  logger.debug({
1295
  "event": "image_urls_resolved",
1296
  "conversation_id": conversation_id,
@@ -1312,7 +1375,8 @@ class OpenAIBackendAPI:
1312
  })
1313
  continue
1314
  if url:
1315
- urls.append(url)
 
1316
  else:
1317
  logger.debug({
1318
  "event": "image_download_url_empty",
@@ -1338,12 +1402,42 @@ class OpenAIBackendAPI:
1338
  ) -> list[str]:
1339
  file_ids = [item for item in file_ids if item != "file_upload"]
1340
  sediment_ids = list(sediment_ids)
1341
- if poll and conversation_id and not file_ids and not sediment_ids:
1342
- logger.info({"event": "image_resolve_poll_needed", "conversation_id": conversation_id})
1343
- polled_file_ids, polled_sediment_ids = self._poll_image_results(conversation_id,
1344
- config.image_poll_timeout_secs)
1345
- file_ids.extend(item for item in polled_file_ids if item and item not in file_ids)
1346
- sediment_ids.extend(item for item in polled_sediment_ids if item and item not in sediment_ids)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1347
  return self._resolve_image_urls(conversation_id, file_ids, sediment_ids)
1348
 
1349
  def download_image_bytes(self, urls: list[str]) -> list[bytes]:
@@ -1351,7 +1445,8 @@ class OpenAIBackendAPI:
1351
  for url in urls:
1352
  response = self.session.get(url, timeout=120)
1353
  ensure_ok(response, "image_download")
1354
- images.append(response.content)
 
1355
  return images
1356
 
1357
  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."
 
1103
  def _clean_search_url(self, value: Any) -> str:
1104
  return str(value or "").strip().rstrip(".,;,。;")
1105
 
1106
+ @staticmethod
1107
+ def _add_unique(values: list[str], candidates: list[str]) -> None:
1108
+ for candidate in candidates:
1109
+ if candidate and candidate not in values:
1110
+ values.append(candidate)
1111
+
1112
+ @classmethod
1113
+ def _extract_image_reference_ids(cls, payload: Any) -> tuple[list[str], list[str]]:
1114
+ file_ids: list[str] = []
1115
+ sediment_ids: list[str] = []
1116
+
1117
+ def walk(value: Any) -> None:
1118
+ if isinstance(value, str):
1119
+ cls._add_unique(file_ids, FILE_SERVICE_ID_RE.findall(value))
1120
+ cls._add_unique(file_ids, FILE_ID_RE.findall(value))
1121
+ cls._add_unique(sediment_ids, SEDIMENT_ID_RE.findall(value))
1122
+ return
1123
+ if isinstance(value, dict):
1124
+ for item in value.values():
1125
+ walk(item)
1126
+ return
1127
+ if isinstance(value, list):
1128
+ for item in value:
1129
+ walk(item)
1130
+
1131
+ walk(payload)
1132
+ return file_ids, sediment_ids
1133
+
1134
+ @classmethod
1135
+ def _has_image_asset_pointer(cls, payload: Any) -> bool:
1136
+ if isinstance(payload, dict):
1137
+ if str(payload.get("content_type") or "") == "image_asset_pointer":
1138
+ return True
1139
+ asset_pointer = str(payload.get("asset_pointer") or "")
1140
+ if asset_pointer.startswith(("file-service://", "sediment://")):
1141
+ return True
1142
+ return any(cls._has_image_asset_pointer(item) for item in payload.values())
1143
+ if isinstance(payload, list):
1144
+ return any(cls._has_image_asset_pointer(item) for item in payload)
1145
+ return False
1146
+
1147
  def _extract_image_tool_records(self, data: Dict[str, Any]) -> list[Dict[str, Any]]:
1148
  """从 conversation 明细里提取图片工具输出记录。"""
1149
  mapping = data.get("mapping") or {}
 
 
1150
  records = []
1151
  for message_id, node in mapping.items():
1152
  message = (node or {}).get("message") or {}
1153
  author = message.get("author") or {}
1154
  metadata = message.get("metadata") or {}
1155
  content = message.get("content") or {}
1156
+ role = str(author.get("role") or "").strip().lower()
1157
+ if role not in {"tool", "assistant"}:
1158
  continue
1159
+ is_image_gen = metadata.get("async_task_type") == "image_gen"
1160
+ has_asset_pointer = self._has_image_asset_pointer(content) or self._has_image_asset_pointer(metadata)
1161
+ if role == "assistant" and not (is_image_gen or has_asset_pointer):
1162
  continue
1163
+ file_ids, sediment_ids = self._extract_image_reference_ids({"content": content, "metadata": metadata})
1164
+ if not is_image_gen and not has_asset_pointer and not file_ids and not sediment_ids:
 
 
 
 
 
 
 
 
 
1165
  continue
1166
  records.append(
1167
  {"message_id": message_id, "create_time": message.get("create_time") or 0, "file_ids": file_ids,
1168
  "sediment_ids": sediment_ids})
1169
  return sorted(records, key=lambda item: item["create_time"])
1170
 
1171
+ def _poll_image_results(
1172
+ self,
1173
+ conversation_id: str,
1174
+ timeout_secs: float = 120.0,
1175
+ initial_file_ids: list[str] | None = None,
1176
+ initial_sediment_ids: list[str] | None = None,
1177
+ ) -> tuple[list[str], list[str]]:
1178
  """Poll the conversation document until image file ids appear or budget runs out.
1179
 
1180
  - Sleeps image_poll_initial_wait_secs first (default 10s, +jitter). ChatGPT
 
1190
  attempt = 0
1191
  interval = float(config.image_poll_interval_secs)
1192
  initial_wait = float(config.image_poll_initial_wait_secs)
1193
+ file_ids: list[str] = []
1194
+ sediment_ids: list[str] = []
1195
+ self._add_unique(file_ids, initial_file_ids or [])
1196
+ self._add_unique(sediment_ids, initial_sediment_ids or [])
1197
+ has_initial_ids = bool(file_ids or sediment_ids)
1198
+ last_hit_key: tuple[tuple[str, ...], tuple[str, ...]] | None = (
1199
+ (tuple(file_ids), tuple(sediment_ids)) if has_initial_ids else None
1200
+ )
1201
  logger.info({
1202
  "event": "image_poll_start",
1203
  "conversation_id": conversation_id,
1204
  "timeout_secs": timeout_secs,
1205
  "initial_wait_secs": initial_wait,
1206
  "interval_secs": interval,
1207
+ "initial_file_ids": file_ids,
1208
+ "initial_sediment_ids": sediment_ids,
1209
  })
1210
 
1211
  def _remaining() -> float:
1212
  return timeout_secs - (time.time() - start)
1213
 
1214
+ if has_initial_ids:
1215
+ settle_for = min(IMAGE_POLL_SETTLE_SECS, max(0.0, _remaining()))
1216
+ if settle_for > 0:
1217
+ time.sleep(settle_for)
1218
+ elif initial_wait > 0:
1219
  jitter = random.uniform(0, min(2.0, initial_wait * 0.2))
1220
  sleep_for = min(initial_wait + jitter, max(0.0, _remaining()))
1221
  if sleep_for > 0:
 
1259
  continue
1260
  break
1261
 
 
1262
  for record in self._extract_image_tool_records(conversation):
1263
  for file_id in record["file_ids"]:
1264
  if file_id not in file_ids:
 
1268
  sediment_ids.append(sediment_id)
1269
  logger.debug({"event": "image_poll_check", "conversation_id": conversation_id, "attempt": attempt,
1270
  "file_ids": file_ids, "sediment_ids": sediment_ids})
1271
+ if file_ids or sediment_ids:
1272
+ hit_key = (tuple(file_ids), tuple(sediment_ids))
1273
+ if last_hit_key == hit_key:
1274
+ logger.info({"event": "image_poll_hit", "conversation_id": conversation_id, "file_ids": file_ids,
1275
+ "sediment_ids": sediment_ids})
1276
+ return file_ids, sediment_ids
1277
+ last_hit_key = hit_key
1278
+ logger.info({"event": "image_poll_hit_pending_settle", "conversation_id": conversation_id,
1279
+ "file_ids": file_ids, "sediment_ids": sediment_ids})
1280
+ wait = min(IMAGE_POLL_SETTLE_SECS, max(0.0, _remaining()))
1281
+ if wait > 0:
1282
+ time.sleep(wait)
1283
+ continue
1284
  return file_ids, sediment_ids
 
 
 
 
1285
  logger.debug({"event": "image_poll_wait", "conversation_id": conversation_id,
1286
  "elapsed_secs": round(time.time() - start, 1)})
1287
  wait = min(interval, max(0.0, _remaining()))
 
1344
  })
1345
  continue
1346
  if url:
1347
+ if url not in urls:
1348
+ urls.append(url)
1349
  else:
1350
  logger.debug({
1351
  "event": "image_download_url_empty",
 
1353
  "conversation_id": conversation_id,
1354
  "id": file_id,
1355
  })
1356
+ if not conversation_id or not sediment_ids:
1357
  logger.debug({
1358
  "event": "image_urls_resolved",
1359
  "conversation_id": conversation_id,
 
1375
  })
1376
  continue
1377
  if url:
1378
+ if url not in urls:
1379
+ urls.append(url)
1380
  else:
1381
  logger.debug({
1382
  "event": "image_download_url_empty",
 
1402
  ) -> list[str]:
1403
  file_ids = [item for item in file_ids if item != "file_upload"]
1404
  sediment_ids = list(sediment_ids)
1405
+ if poll and conversation_id:
1406
+ logger.info({
1407
+ "event": "image_resolve_poll_needed",
1408
+ "conversation_id": conversation_id,
1409
+ "initial_file_ids": file_ids,
1410
+ "initial_sediment_ids": sediment_ids,
1411
+ })
1412
+ try:
1413
+ polled_file_ids, polled_sediment_ids = self._poll_image_results(
1414
+ conversation_id,
1415
+ config.image_poll_timeout_secs,
1416
+ file_ids,
1417
+ sediment_ids,
1418
+ )
1419
+ except ImagePollTimeoutError:
1420
+ if not file_ids and not sediment_ids:
1421
+ raise
1422
+ logger.warning({
1423
+ "event": "image_resolve_poll_partial_timeout",
1424
+ "conversation_id": conversation_id,
1425
+ "file_ids": file_ids,
1426
+ "sediment_ids": sediment_ids,
1427
+ })
1428
+ except Exception as exc:
1429
+ if not file_ids and not sediment_ids:
1430
+ raise
1431
+ logger.warning({
1432
+ "event": "image_resolve_poll_partial_error",
1433
+ "conversation_id": conversation_id,
1434
+ "file_ids": file_ids,
1435
+ "sediment_ids": sediment_ids,
1436
+ "error": repr(exc),
1437
+ })
1438
+ else:
1439
+ file_ids.extend(item for item in polled_file_ids if item and item not in file_ids)
1440
+ sediment_ids.extend(item for item in polled_sediment_ids if item and item not in sediment_ids)
1441
  return self._resolve_image_urls(conversation_id, file_ids, sediment_ids)
1442
 
1443
  def download_image_bytes(self, urls: list[str]) -> list[bytes]:
 
1445
  for url in urls:
1446
  response = self.session.get(url, timeout=120)
1447
  ensure_ok(response, "image_download")
1448
+ if response.content not in images:
1449
+ images.append(response.content)
1450
  return images
1451
 
1452
  def stream_conversation(
services/protocol/conversation.py CHANGED
@@ -429,12 +429,19 @@ def add_unique(values: list[str], candidates: list[str]) -> None:
429
  values.append(candidate)
430
 
431
 
 
 
 
 
 
432
  def extract_conversation_ids(payload: str) -> tuple[str, list[str], list[str]]:
433
  conversation_match = re.search(r'"conversation_id"\s*:\s*"([^"]+)"', payload)
434
  conversation_id = conversation_match.group(1) if conversation_match else ""
 
435
  # Negative lookahead excludes "file-service" (URI prefix, not a real id).
436
- file_ids = re.findall(r"(file[-_](?!service\b)[A-Za-z0-9]+)", payload)
437
- sediment_ids = re.findall(r"sediment://([A-Za-z0-9_-]+)", payload)
 
438
  return conversation_id, file_ids, sediment_ids
439
 
440
 
 
429
  values.append(candidate)
430
 
431
 
432
+ FILE_SERVICE_ID_RE = re.compile(r"file-service://([A-Za-z0-9_-]+)")
433
+ FILE_ID_RE = re.compile(r"\b(file[-_](?!service\b)[A-Za-z0-9_-]+)\b")
434
+ SEDIMENT_ID_RE = re.compile(r"sediment://([A-Za-z0-9_-]+)")
435
+
436
+
437
  def extract_conversation_ids(payload: str) -> tuple[str, list[str], list[str]]:
438
  conversation_match = re.search(r'"conversation_id"\s*:\s*"([^"]+)"', payload)
439
  conversation_id = conversation_match.group(1) if conversation_match else ""
440
+ file_ids: list[str] = []
441
  # Negative lookahead excludes "file-service" (URI prefix, not a real id).
442
+ add_unique(file_ids, FILE_SERVICE_ID_RE.findall(payload))
443
+ add_unique(file_ids, FILE_ID_RE.findall(payload))
444
+ sediment_ids = SEDIMENT_ID_RE.findall(payload)
445
  return conversation_id, file_ids, sediment_ids
446
 
447
 
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()