Accept OpenAI multipart image edits

This commit is contained in:
Mikei386
2026-09-21 11:35:52 +02:00
parent 9509d8ce29
commit a65affe30a
2 changed files with 94 additions and 10 deletions
+40
View File
@@ -24,6 +24,7 @@ from router_support import ( # noqa: E402
load_profile_registry, load_profile_registry,
) )
from ai_profile_router import ( # noqa: E402 from ai_profile_router import ( # noqa: E402
Handler,
STATE, STATE,
_cap_chat_generation, _cap_chat_generation,
_context_matches, _context_matches,
@@ -141,6 +142,45 @@ class ChatImageInputTests(unittest.TestCase):
self.assertEqual(request, normalized) self.assertEqual(request, normalized)
class ImageEditMultipartTests(unittest.TestCase):
def test_openai_image_array_upload_keeps_all_references(self) -> None:
boundary = "OpenClawImageBoundary"
parts = [
(
f"--{boundary}\r\n"
'Content-Disposition: form-data; name="model"\r\n\r\n'
"Qwen-Image-2.1-int8\r\n"
).encode(),
(
f"--{boundary}\r\n"
'Content-Disposition: form-data; name="prompt"\r\n\r\n'
"Nur die Farbe ändern\r\n"
).encode(),
(
f"--{boundary}\r\n"
'Content-Disposition: form-data; name="image[]"; filename="a.png"\r\n'
"Content-Type: image/png\r\n\r\n"
).encode() + b"PNG-A\r\n",
(
f"--{boundary}\r\n"
'Content-Disposition: form-data; name="image[]"; filename="b.png"\r\n'
"Content-Type: image/png\r\n\r\n"
).encode() + b"PNG-B\r\n",
f"--{boundary}--\r\n".encode(),
]
handler = object.__new__(Handler)
files, fields = handler._parse_multipart_parts(
b"".join(parts), f"multipart/form-data; boundary={boundary}")
self.assertEqual(fields["model"], "Qwen-Image-2.1-int8")
self.assertEqual(fields["prompt"], "Nur die Farbe ändern")
self.assertEqual(
files,
[("image[]", "a.png", b"PNG-A"),
("image[]", "b.png", b"PNG-B")],
)
class LlamaCppReasoningTests(unittest.TestCase): class LlamaCppReasoningTests(unittest.TestCase):
def test_disabled_values_really_disable_thinking(self) -> None: def test_disabled_values_really_disable_thinking(self) -> None:
for effort in (None, "none", "off", "disabled", False): for effort in (None, "none", "off", "disabled", False):
+54 -10
View File
@@ -2223,7 +2223,25 @@ class Handler(BaseHTTPRequestHandler):
self._image_request(data, []) self._image_request(data, [])
def _image_edit(self) -> None: def _image_edit(self) -> None:
"""Edit with local image bytes supplied by the private Hermes plugin.""" """Edit with OpenAI multipart uploads or legacy JSON/base64 input."""
content_type = self.headers.get("Content-Type", "")
if "multipart/form-data" in content_type:
try:
body = self._read_body()
files, data = self._parse_multipart_parts(body, content_type)
except ValueError as exc:
self._send_error(400, str(exc),
"invalid_request_error", "invalid_multipart")
return
images = [payload for name, _filename, payload in files
if name in {"image", "image[]"}]
if not images:
self._send_error(400, "Referenzbild fehlt",
"invalid_request_error", "missing_image")
return
self._image_edit_bytes(data, images)
return
data = self._read_image_request() data = self._read_image_request()
if data is None: if data is None:
return return
@@ -2249,7 +2267,7 @@ class Handler(BaseHTTPRequestHandler):
"invalid_request_error", "too_many_images") "invalid_request_error", "too_many_images")
return return
source_files: list[str] = [] decoded: list[bytes] = []
try: try:
for item in encoded: for item in encoded:
if item.startswith("data:"): if item.startswith("data:"):
@@ -2260,6 +2278,25 @@ class Handler(BaseHTTPRequestHandler):
raw = base64.b64decode(item, validate=True) raw = base64.b64decode(item, validate=True)
except Exception as exc: except Exception as exc:
raise ValueError("ungültige Base64-Bilddaten") from exc raise ValueError("ungültige Base64-Bilddaten") from exc
decoded.append(raw)
self._image_edit_bytes(data, decoded)
except ValueError as exc:
self._send_error(400, str(exc),
"invalid_request_error", "invalid_image")
def _image_edit_bytes(self, data: dict, images: list[bytes]) -> None:
if len(images) > 4:
self._send_error(400, "höchstens vier Referenzbilder erlaubt",
"invalid_request_error", "too_many_images")
return
if not images:
self._send_error(400, "Referenzbild fehlt",
"invalid_request_error", "missing_image")
return
source_files: list[str] = []
try:
for raw in images:
if not raw or len(raw) > CHAT_IMAGE_MAX_BYTES: if not raw or len(raw) > CHAT_IMAGE_MAX_BYTES:
raise ValueError( raise ValueError(
f"Referenzbild muss 1..{CHAT_IMAGE_MAX_BYTES} Bytes groß sein") f"Referenzbild muss 1..{CHAT_IMAGE_MAX_BYTES} Bytes groß sein")
@@ -2652,9 +2689,9 @@ class Handler(BaseHTTPRequestHandler):
# ---------- STT (Spracherkennung) ---------- # ---------- STT (Spracherkennung) ----------
def _parse_multipart(self, data: bytes, content_type: str def _parse_multipart_parts(self, data: bytes, content_type: str
) -> tuple[bytes, str, dict]: ) -> tuple[list[tuple[str, str, bytes]], dict]:
"""Parst multipart/form-data. Liefert (file_data, filename, fields). """Parst alle Datei- und Textteile eines multipart/form-data-Body.
Nutzt email.parser.BytesParser (Standardbibliothek) für robustes Nutzt email.parser.BytesParser (Standardbibliothek) für robustes
MIME-Parsing. Handhabt quoted und unquoted Boundaries, beliebige MIME-Parsing. Handhabt quoted und unquoted Boundaries, beliebige
@@ -2667,9 +2704,8 @@ class Handler(BaseHTTPRequestHandler):
if not msg.is_multipart(): if not msg.is_multipart():
raise ValueError("Kein multipart/form-data") raise ValueError("Kein multipart/form-data")
file_data = b"" files: list[tuple[str, str, bytes]] = []
filename = "" fields: dict[str, str] = {}
fields = {}
for part in msg.get_payload(): for part in msg.get_payload():
disposition = part.get("Content-Disposition", "") disposition = part.get("Content-Disposition", "")
@@ -2690,12 +2726,20 @@ class Handler(BaseHTTPRequestHandler):
if part_filename is not None: if part_filename is not None:
# Dateifeld (binär, nicht dekodieren) # Dateifeld (binär, nicht dekodieren)
file_data = payload files.append((name, part_filename or "", payload))
filename = part_filename or ""
else: else:
# Textfeld # Textfeld
fields[name] = payload.decode("utf-8", errors="replace") fields[name] = payload.decode("utf-8", errors="replace")
return files, fields
def _parse_multipart(self, data: bytes, content_type: str
) -> tuple[bytes, str, dict]:
"""Kompatibler Einzeldatei-Wrapper für den STT-Pfad."""
files, fields = self._parse_multipart_parts(data, content_type)
if not files:
return b"", "", fields
_name, filename, file_data = files[-1]
return file_data, filename, fields return file_data, filename, fields
def _transcribe(self) -> None: def _transcribe(self) -> None: