Accept OpenAI multipart image edits
This commit is contained in:
@@ -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
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user