thynome commited on
Commit ·
2d350f2
1
Parent(s): 47c35ad
Improve vision input handling and text sanitization
Browse files- api/ai.py +17 -3
- services/content_filter.py +46 -0
- services/log_service.py +3 -0
- services/protocol/conversation.py +37 -12
- services/protocol/openai_v1_response.py +1 -1
- test/test_chat_completion_cache.py +32 -2
- utils/helper.py +36 -9
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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 325 |
|
| 326 |
# ChatGPT web sometimes returns rich annotation markers using private-use
|
| 327 |
-
# characters. API clients cannot render those
|
| 328 |
-
#
|
| 329 |
-
text = re.sub(r"\
|
| 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
|
| 324 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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
|
| 381 |
-
|
| 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
|