Accept OpenAI multipart image edits
This commit is contained in:
@@ -24,6 +24,7 @@ from router_support import ( # noqa: E402
|
||||
load_profile_registry,
|
||||
)
|
||||
from ai_profile_router import ( # noqa: E402
|
||||
Handler,
|
||||
STATE,
|
||||
_cap_chat_generation,
|
||||
_context_matches,
|
||||
@@ -141,6 +142,45 @@ class ChatImageInputTests(unittest.TestCase):
|
||||
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):
|
||||
def test_disabled_values_really_disable_thinking(self) -> None:
|
||||
for effort in (None, "none", "off", "disabled", False):
|
||||
|
||||
+54
-10
@@ -2223,7 +2223,25 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self._image_request(data, [])
|
||||
|
||||
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()
|
||||
if data is None:
|
||||
return
|
||||
@@ -2249,7 +2267,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
"invalid_request_error", "too_many_images")
|
||||
return
|
||||
|
||||
source_files: list[str] = []
|
||||
decoded: list[bytes] = []
|
||||
try:
|
||||
for item in encoded:
|
||||
if item.startswith("data:"):
|
||||
@@ -2260,6 +2278,25 @@ class Handler(BaseHTTPRequestHandler):
|
||||
raw = base64.b64decode(item, validate=True)
|
||||
except Exception as 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:
|
||||
raise ValueError(
|
||||
f"Referenzbild muss 1..{CHAT_IMAGE_MAX_BYTES} Bytes groß sein")
|
||||
@@ -2652,9 +2689,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
# ---------- STT (Spracherkennung) ----------
|
||||
|
||||
def _parse_multipart(self, data: bytes, content_type: str
|
||||
) -> tuple[bytes, str, dict]:
|
||||
"""Parst multipart/form-data. Liefert (file_data, filename, fields).
|
||||
def _parse_multipart_parts(self, data: bytes, content_type: str
|
||||
) -> tuple[list[tuple[str, str, bytes]], dict]:
|
||||
"""Parst alle Datei- und Textteile eines multipart/form-data-Body.
|
||||
|
||||
Nutzt email.parser.BytesParser (Standardbibliothek) für robustes
|
||||
MIME-Parsing. Handhabt quoted und unquoted Boundaries, beliebige
|
||||
@@ -2667,9 +2704,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if not msg.is_multipart():
|
||||
raise ValueError("Kein multipart/form-data")
|
||||
|
||||
file_data = b""
|
||||
filename = ""
|
||||
fields = {}
|
||||
files: list[tuple[str, str, bytes]] = []
|
||||
fields: dict[str, str] = {}
|
||||
|
||||
for part in msg.get_payload():
|
||||
disposition = part.get("Content-Disposition", "")
|
||||
@@ -2690,12 +2726,20 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
if part_filename is not None:
|
||||
# Dateifeld (binär, nicht dekodieren)
|
||||
file_data = payload
|
||||
filename = part_filename or ""
|
||||
files.append((name, part_filename or "", payload))
|
||||
else:
|
||||
# Textfeld
|
||||
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
|
||||
|
||||
def _transcribe(self) -> None:
|
||||
|
||||
Reference in New Issue
Block a user