thynome commited on
Commit
2d350f2
·
1 Parent(s): 47c35ad

Improve vision input handling and text sanitization

Browse files
api/ai.py CHANGED
@@ -6,7 +6,7 @@ from pydantic import BaseModel, ConfigDict, Field
6
 
7
  from api.image_inputs import parse_image_edit_request, read_image_sources
8
  from api.support import require_identity, resolve_image_base_url
9
- from services.content_filter import check_request, request_text
10
  from services.log_service import LoggedCall
11
  from services.protocol import (
12
  anthropic_v1_messages,
@@ -114,7 +114,14 @@ def create_router() -> APIRouter:
114
  payload = body.model_dump(mode="python")
115
  model = str(payload.get("model") or "auto")
116
  request_preview = request_text(payload.get("prompt"), payload.get("messages"))
117
- call = LoggedCall(identity, "/v1/chat/completions", model, "文本生成", request_text=request_preview)
 
 
 
 
 
 
 
118
  await filter_or_log(call, request_preview)
119
  return await call.run(openai_v1_chat_complete.handle, payload)
120
 
@@ -124,7 +131,14 @@ def create_router() -> APIRouter:
124
  payload = body.model_dump(mode="python")
125
  model = str(payload.get("model") or "auto")
126
  request_preview = request_text(payload.get("input"), payload.get("instructions"))
127
- call = LoggedCall(identity, "/v1/responses", model, "Responses", request_text=request_preview)
 
 
 
 
 
 
 
128
  await filter_or_log(call, request_preview)
129
  return await call.run(openai_v1_response.handle, payload)
130
 
 
6
 
7
  from api.image_inputs import parse_image_edit_request, read_image_sources
8
  from api.support import require_identity, resolve_image_base_url
9
+ from services.content_filter import check_request, request_shape, request_text
10
  from services.log_service import LoggedCall
11
  from services.protocol import (
12
  anthropic_v1_messages,
 
114
  payload = body.model_dump(mode="python")
115
  model = str(payload.get("model") or "auto")
116
  request_preview = request_text(payload.get("prompt"), payload.get("messages"))
117
+ call = LoggedCall(
118
+ identity,
119
+ "/v1/chat/completions",
120
+ model,
121
+ "文本生成",
122
+ request_text=request_preview,
123
+ request_shape=request_shape(payload.get("messages")),
124
+ )
125
  await filter_or_log(call, request_preview)
126
  return await call.run(openai_v1_chat_complete.handle, payload)
127
 
 
131
  payload = body.model_dump(mode="python")
132
  model = str(payload.get("model") or "auto")
133
  request_preview = request_text(payload.get("input"), payload.get("instructions"))
134
+ call = LoggedCall(
135
+ identity,
136
+ "/v1/responses",
137
+ model,
138
+ "Responses",
139
+ request_text=request_preview,
140
+ request_shape=request_shape(payload.get("input")),
141
+ )
142
  await filter_or_log(call, request_preview)
143
  return await call.run(openai_v1_response.handle, payload)
144
 
services/content_filter.py CHANGED
@@ -37,6 +37,52 @@ def request_text(*values: object) -> str:
37
  return "\n".join(part for value in values if (part := _text(value).strip()))
38
 
39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  def _sanitize_for_review(text: str) -> tuple[str, dict[str, int]]:
41
  """Strip base64 data URIs and truncate to the review-service context limit.
42
 
 
37
  return "\n".join(part for value in values if (part := _text(value).strip()))
38
 
39
 
40
+ def request_shape(*values: object) -> dict[str, int]:
41
+ """Return a safe structural summary without logging prompts or image bytes."""
42
+ stats = {
43
+ "response_message_items": 0,
44
+ "input_image_parts": 0,
45
+ "image_url_parts": 0,
46
+ "image_parts": 0,
47
+ "data_url_images": 0,
48
+ "remote_image_urls": 0,
49
+ "literal_image_placeholders": 0,
50
+ }
51
+
52
+ def walk(value: object, key: str = "") -> None:
53
+ if isinstance(value, str):
54
+ text = value.strip()
55
+ lower = text.lower()
56
+ if "<image>" in lower:
57
+ stats["literal_image_placeholders"] += 1
58
+ if lower.startswith("data:image/"):
59
+ stats["data_url_images"] += 1
60
+ elif key in {"image_url", "url"} and lower.startswith(("http://", "https://")):
61
+ stats["remote_image_urls"] += 1
62
+ return
63
+ if isinstance(value, list):
64
+ for item in value:
65
+ walk(item, key)
66
+ return
67
+ if not isinstance(value, dict):
68
+ return
69
+ item_type = str(value.get("type") or "").strip()
70
+ if item_type == "message":
71
+ stats["response_message_items"] += 1
72
+ elif item_type == "input_image":
73
+ stats["input_image_parts"] += 1
74
+ elif item_type == "image_url":
75
+ stats["image_url_parts"] += 1
76
+ elif item_type == "image":
77
+ stats["image_parts"] += 1
78
+ for child_key, child in value.items():
79
+ walk(child, str(child_key))
80
+
81
+ for value in values:
82
+ walk(value)
83
+ return {key: value for key, value in stats.items() if value}
84
+
85
+
86
  def _sanitize_for_review(text: str) -> tuple[str, dict[str, int]]:
87
  """Strip base64 data URIs and truncate to the review-service context limit.
88
 
services/log_service.py CHANGED
@@ -208,6 +208,7 @@ class LoggedCall:
208
  summary: str
209
  started: float = field(default_factory=time.time)
210
  request_text: str = ""
 
211
 
212
  async def run(self, handler, *args, sse: str = "openai"):
213
  from services.protocol.conversation import ImageGenerationError
@@ -294,6 +295,8 @@ class LoggedCall:
294
  request_excerpt = _request_excerpt(self.request_text)
295
  if request_excerpt:
296
  detail["request_text"] = request_excerpt
 
 
297
  if error:
298
  detail["error"] = error
299
  email = str(account_email or "").strip()
 
208
  summary: str
209
  started: float = field(default_factory=time.time)
210
  request_text: str = ""
211
+ request_shape: dict[str, int] | None = None
212
 
213
  async def run(self, handler, *args, sse: str = "openai"):
214
  from services.protocol.conversation import ImageGenerationError
 
295
  request_excerpt = _request_excerpt(self.request_text)
296
  if request_excerpt:
297
  detail["request_text"] = request_excerpt
298
+ if self.request_shape:
299
+ detail["request_shape"] = self.request_shape
300
  if error:
301
  detail["error"] = error
302
  email = str(account_email or "").strip()
services/protocol/conversation.py CHANGED
@@ -124,7 +124,7 @@ def normalize_messages(messages: object, system: Any = None) -> list[dict[str, A
124
  if not isinstance(part, dict) or part.get("type") != "image":
125
  continue
126
  data = part.get("data")
127
- if isinstance(data, (bytes, bytearray)):
128
  images.append((bytes(data), str(part.get("mime") or "image/png")))
129
  if images:
130
  parts: list[Any] = []
@@ -316,20 +316,45 @@ def strip_history(text: str, history_text: str = "") -> str:
316
  def sanitize_output_text(text: str) -> str:
317
  text = str(text or "")
318
 
319
- def replace_url(match: re.Match[str]) -> str:
320
- label = match.group(1).strip()
321
- url = match.group(2).strip()
322
- if label and url.startswith(("http://", "https://")):
323
- return f"{label} ({url})"
324
- return label or url
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
325
 
326
  # ChatGPT web sometimes returns rich annotation markers using private-use
327
- # characters. API clients cannot render those, so convert links and drop
328
- # citations before emitting OpenAI-compatible text.
329
- text = re.sub(r"\ue200url\ue202([^\ue202\ue201]*)\ue202([^\ue201]*)\ue201", replace_url, text)
330
- text = re.sub(r"\ue200cite\ue202[^\ue201]*\ue201", "", text)
331
- text = re.sub(r"\ue200[^\ue201]*\ue201", "", text)
332
  text = re.sub(r"\ue200[^\ue201]*$", "", text)
 
333
  return text
334
 
335
 
 
124
  if not isinstance(part, dict) or part.get("type") != "image":
125
  continue
126
  data = part.get("data")
127
+ if isinstance(data, (bytes, bytearray)) and all(existing[0] != bytes(data) for existing in images):
128
  images.append((bytes(data), str(part.get("mime") or "image/png")))
129
  if images:
130
  parts: list[Any] = []
 
316
  def sanitize_output_text(text: str) -> str:
317
  text = str(text or "")
318
 
319
+ def is_internal_annotation_part(part: str) -> bool:
320
+ value = part.strip()
321
+ if not value:
322
+ return True
323
+ lower = value.lower()
324
+ return bool(
325
+ re.fullmatch(r"turn\d+[a-z]*\d*", lower)
326
+ or re.fullmatch(r"turn\d+\w*", lower)
327
+ or lower.startswith(("turn", "source", "sources"))
328
+ )
329
+
330
+ def readable_annotation_part(parts: list[str]) -> str:
331
+ for part in parts:
332
+ value = part.strip()
333
+ if value and not is_internal_annotation_part(value):
334
+ return value
335
+ return ""
336
+
337
+ def replace_annotation(match: re.Match[str]) -> str:
338
+ payload = match.group(1)
339
+ parts = [part.strip() for part in payload.split("\ue202")]
340
+ kind = (parts[0] if parts else "").lower()
341
+ data = parts[1:]
342
+ if kind == "url":
343
+ label = data[0] if data else ""
344
+ url = data[1] if len(data) > 1 else ""
345
+ if label and url.startswith(("http://", "https://")):
346
+ return f"{label} ({url})"
347
+ return label or url
348
+ if kind == "cite":
349
+ return readable_annotation_part(data)
350
+ return readable_annotation_part(data)
351
 
352
  # ChatGPT web sometimes returns rich annotation markers using private-use
353
+ # characters. API clients cannot render those. Preserve readable labels
354
+ # from entity/link annotations, while removing internal citation pointers.
355
+ text = re.sub(r"\ue200([^\ue201]*)\ue201", replace_annotation, text)
 
 
356
  text = re.sub(r"\ue200[^\ue201]*$", "", text)
357
+ text = re.sub(r"\s+([.,;:!?])", r"\1", text)
358
  return text
359
 
360
 
services/protocol/openai_v1_response.py CHANGED
@@ -33,7 +33,7 @@ TOOL_UNAVAILABLE_SYSTEM_MESSAGE = (
33
  "If a user asks you to use a tool, say that tool execution is unavailable through this backend."
34
  )
35
 
36
- RESPONSE_CONTENT_PART_TYPES = {"text", "input_text", "output_text", "image_url", "input_image"}
37
 
38
 
39
  def is_text_response_request(body: dict[str, Any]) -> bool:
 
33
  "If a user asks you to use a tool, say that tool execution is unavailable through this backend."
34
  )
35
 
36
+ RESPONSE_CONTENT_PART_TYPES = {"text", "input_text", "output_text", "image_url", "input_image", "image"}
37
 
38
 
39
  def is_text_response_request(body: dict[str, Any]) -> bool:
test/test_chat_completion_cache.py CHANGED
@@ -9,6 +9,7 @@ from services.config import config
9
  from services.protocol import openai_v1_chat_complete, openai_v1_response
10
  from services.protocol.chat_completion_cache import chat_completion_cache
11
  from services.protocol.conversation import iter_conversation_payloads, sanitize_output_text
 
12
 
13
 
14
  PNG_1X1 = base64.b64decode(
@@ -190,9 +191,25 @@ class ChatCompletionCacheTests(unittest.TestCase):
190
 
191
  self.assertEqual(
192
  sanitize_output_text(text),
193
- "Repo: basketikun/chatgpt2api (https://github.com/basketikun/chatgpt2api) details .",
194
  )
195
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
  def test_stream_sanitizer_does_not_emit_partial_annotation_or_repeat_prefix(self) -> None:
197
  events = [
198
  {"p": "/message/content/parts/0", "o": "append", "v": "Repo: \ue200url\ue202chat"},
@@ -206,7 +223,7 @@ class ChatCompletionCacheTests(unittest.TestCase):
206
  if event.get("type") == "conversation.delta"
207
  ]
208
 
209
- self.assertEqual("".join(deltas), "Repo: chatgpt2api done .")
210
  self.assertFalse(any("\ue200" in delta or "\ue202" in delta or "\ue201" in delta for delta in deltas))
211
 
212
  def test_responses_tools_add_honest_no_tool_guard(self) -> None:
@@ -301,6 +318,19 @@ class ChatCompletionCacheTests(unittest.TestCase):
301
  self.assertEqual(content[1]["data"], PNG_1X1)
302
  self.assertEqual(content[1]["mime"], "image/png")
303
 
 
 
 
 
 
 
 
 
 
 
 
 
 
304
 
305
  if __name__ == "__main__":
306
  unittest.main()
 
9
  from services.protocol import openai_v1_chat_complete, openai_v1_response
10
  from services.protocol.chat_completion_cache import chat_completion_cache
11
  from services.protocol.conversation import iter_conversation_payloads, sanitize_output_text
12
+ from utils.helper import extract_image_from_message_content
13
 
14
 
15
  PNG_1X1 = base64.b64decode(
 
191
 
192
  self.assertEqual(
193
  sanitize_output_text(text),
194
+ "Repo: basketikun/chatgpt2api (https://github.com/basketikun/chatgpt2api) details.",
195
  )
196
 
197
+ def test_output_sanitizer_preserves_annotated_entity_text(self) -> None:
198
+ text = (
199
+ "The character is from \ue200entity\ue202Invincible\ue201, "
200
+ "which is based on the comic series \ue200entity\ue202Invincible\ue201."
201
+ )
202
+
203
+ self.assertEqual(
204
+ sanitize_output_text(text),
205
+ "The character is from Invincible, which is based on the comic series Invincible.",
206
+ )
207
+
208
+ def test_output_sanitizer_preserves_readable_cite_label(self) -> None:
209
+ text = "The character is \ue200cite\ue202Invincible\ue202turn0search0\ue201."
210
+
211
+ self.assertEqual(sanitize_output_text(text), "The character is Invincible.")
212
+
213
  def test_stream_sanitizer_does_not_emit_partial_annotation_or_repeat_prefix(self) -> None:
214
  events = [
215
  {"p": "/message/content/parts/0", "o": "append", "v": "Repo: \ue200url\ue202chat"},
 
223
  if event.get("type") == "conversation.delta"
224
  ]
225
 
226
+ self.assertEqual("".join(deltas), "Repo: chatgpt2api done.")
227
  self.assertFalse(any("\ue200" in delta or "\ue202" in delta or "\ue201" in delta for delta in deltas))
228
 
229
  def test_responses_tools_add_honest_no_tool_guard(self) -> None:
 
318
  self.assertEqual(content[1]["data"], PNG_1X1)
319
  self.assertEqual(content[1]["mime"], "image/png")
320
 
321
+ def test_image_extractor_supports_extra_image_object_shapes(self) -> None:
322
+ encoded = base64.b64encode(PNG_1X1).decode("ascii")
323
+
324
+ images = extract_image_from_message_content([
325
+ {"type": "image", "data": PNG_1X1, "mime": "image/png"},
326
+ {"type": "input_image", "base64": encoded, "mime_type": "image/png"},
327
+ {"type": "input_image", "source": {"type": "base64", "data": encoded, "media_type": "image/png"}},
328
+ ])
329
+
330
+ self.assertEqual(len(images), 3)
331
+ self.assertEqual([image[1] for image in images], ["image/png", "image/png", "image/png"])
332
+ self.assertTrue(all(image[0] == PNG_1X1 for image in images))
333
+
334
 
335
  if __name__ == "__main__":
336
  unittest.main()
utils/helper.py CHANGED
@@ -320,8 +320,14 @@ def extract_prompt_from_message_content(content: object) -> str:
320
  return "\n".join(parts).strip()
321
 
322
 
323
- def _decode_message_image_url(url: str) -> tuple[bytes, str] | None:
324
- source = str(url or "").strip()
 
 
 
 
 
 
325
  if source.startswith("data:"):
326
  header, _, data = source.partition(",")
327
  mime = header.split(";")[0].removeprefix("data:") or "image/png"
@@ -363,6 +369,31 @@ def _decode_message_image_url(url: str) -> tuple[bytes, str] | None:
363
  return image_data, mime
364
 
365
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
366
  def extract_image_from_message_content(content: object) -> list[tuple[bytes, str]]:
367
  if not isinstance(content, list):
368
  return []
@@ -372,15 +403,11 @@ def extract_image_from_message_content(content: object) -> list[tuple[bytes, str
372
  continue
373
  item_type = str(item.get("type") or "").strip()
374
  if item_type == "image_url":
375
- url_obj = item.get("image_url") or item
376
- url = str(url_obj.get("url") or "") if isinstance(url_obj, dict) else str(url_obj)
377
- image = _decode_message_image_url(url)
378
  if image:
379
  images.append(image)
380
- elif item_type == "input_image":
381
- url_obj = item.get("image_url") or item.get("url") or ""
382
- image_url = str(url_obj.get("url") or url_obj.get("image_url") or "") if isinstance(url_obj, dict) else str(url_obj)
383
- image = _decode_message_image_url(image_url)
384
  if image:
385
  images.append(image)
386
  return images
 
320
  return "\n".join(parts).strip()
321
 
322
 
323
+ def _message_image_url(value: object) -> str:
324
+ if isinstance(value, dict):
325
+ return str(value.get("url") or value.get("image_url") or "").strip()
326
+ return str(value or "").strip()
327
+
328
+
329
+ def _decode_message_image_url(value: object) -> tuple[bytes, str] | None:
330
+ source = _message_image_url(value)
331
  if source.startswith("data:"):
332
  header, _, data = source.partition(",")
333
  mime = header.split(";")[0].removeprefix("data:") or "image/png"
 
369
  return image_data, mime
370
 
371
 
372
+ def _decode_message_image_object(item: dict[str, object]) -> tuple[bytes, str] | None:
373
+ data = item.get("data")
374
+ if isinstance(data, (bytes, bytearray)):
375
+ return bytes(data), str(item.get("mime") or item.get("mime_type") or "image/png")
376
+ for key in ("image_url", "url"):
377
+ image = _decode_message_image_url(item.get(key))
378
+ if image:
379
+ return image
380
+ value = item.get("b64_json") or item.get("base64")
381
+ if isinstance(value, str) and value.strip():
382
+ image_data, _, mime = _decode_json_image_string(
383
+ value,
384
+ 1,
385
+ mime_type=str(item.get("mime") or item.get("mime_type") or item.get("mimeType") or "image/png"),
386
+ )
387
+ return image_data, mime
388
+ source = item.get("source")
389
+ if isinstance(source, dict) and str(source.get("type") or "") == "base64":
390
+ encoded = str(source.get("data") or "")
391
+ mime = str(source.get("media_type") or source.get("mime_type") or "image/png")
392
+ image_data, _, resolved_mime = _decode_json_image_string(encoded, 1, mime_type=mime)
393
+ return image_data, resolved_mime
394
+ return None
395
+
396
+
397
  def extract_image_from_message_content(content: object) -> list[tuple[bytes, str]]:
398
  if not isinstance(content, list):
399
  return []
 
403
  continue
404
  item_type = str(item.get("type") or "").strip()
405
  if item_type == "image_url":
406
+ image = _decode_message_image_url(item.get("image_url") or item.get("url") or item)
 
 
407
  if image:
408
  images.append(image)
409
+ elif item_type in {"input_image", "image"}:
410
+ image = _decode_message_image_object(item)
 
 
411
  if image:
412
  images.append(image)
413
  return images