Bild-Weiche v2: Coder-Ziele bekommen Vision-BESCHREIBUNG statt Umleitung

Idee aus verwaistem (nie verdrahtetem) Patch in der Hermes-Quelle
api_server.py - regelkonform in den MC2-Gateway umgezogen, Original
als docs/archiv/hermes-api_server-vision-patch-verwaist.diff archiviert.
Request mit Bild an coder: Bilder werden vorab von VL-30B beschrieben
und als Text injiziert, die Code-Frage bleibt beim Spezialisten.
Fallback bei Analyse-Fehler: bisherige Vision-Umleitung.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-15 09:46:36 +02:00
parent 960bda6021
commit 0354ce999f
3 changed files with 235 additions and 10 deletions
@@ -0,0 +1,164 @@
diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py
index 628713224..415f3b2dc 100644
--- a/gateway/platforms/api_server.py
+++ b/gateway/platforms/api_server.py
@@ -362,6 +362,159 @@ def _multimodal_validation_error(exc: ValueError, *, param: str) -> "web.Respons
)
+def _is_coder_model(model_name: Optional[str]) -> bool:
+ """Return True if model_name looks like a coder model (case-insensitive 'coder' in name)."""
+ if not model_name:
+ return False
+ return "coder" in str(model_name).lower()
+
+
+def _extract_image_urls_from_messages(messages: List[Dict[str, Any]]) -> List[str]:
+ """Extract all image URLs from OpenAI-style messages payload."""
+ image_urls: List[str] = []
+ for msg in messages:
+ if not isinstance(msg, dict):
+ continue
+ content = msg.get("content")
+ if not content:
+ continue
+ # Normalize multimodal content to list of parts
+ parts: Any = content
+ if isinstance(content, str):
+ continue # text-only message has no images
+ if not isinstance(parts, list):
+ continue
+ for part in parts:
+ if not isinstance(part, dict):
+ continue
+ part_type = str(part.get("type", "")).strip().lower()
+ if part_type in {"image_url", "input_image"}:
+ img_ref = part.get("image_url")
+ if isinstance(img_ref, dict):
+ url = img_ref.get("url")
+ else:
+ url = img_ref
+ if isinstance(url, str) and url.strip():
+ image_urls.append(url.strip())
+ return image_urls
+
+
+async def _maybe_enrich_with_vision_if_coder(
+ body: Dict[str, Any],
+) -> Dict[str, Any]:
+ """
+ For coder model requests with image attachments, pre-analyze images
+ with the vision fallback model and replace image parts with descriptive text.
+
+ Returns a modified copy of body with image URLs replaced by text
+ descriptions in the messages array.
+ """
+ model_name = body.get("model")
+ if not _is_coder_model(model_name):
+ return body
+
+ messages = body.get("messages")
+ if not messages or not isinstance(messages, list):
+ return body
+
+ image_urls = _extract_image_urls_from_messages(messages)
+ if not image_urls:
+ return body
+
+ # Import here to avoid circular dependency issues
+ try:
+ from tools.vision_tools import vision_analyze_tool
+ except ImportError:
+ logger.warning(
+ "Vision tool not available for coder model enrichment. "
+ "Install hermes-agent tools to enable this feature."
+ )
+ return body
+
+ # Use the configured vision model or fall back to VL-30B
+ # We'll let vision_analyze_tool use its own default (auxiliary.vision)
+ # but we can optionally override by passing model parameter
+ # For now, let it auto-resolve via config
+ analysis_prompt = (
+ "Describe everything visible in this image in thorough detail. "
+ "Include any text, code, data, objects, people, layout, colors, "
+ "and any other notable visual information."
+ )
+
+ # Build a mapping of URL -> description
+ url_to_desc: Dict[str, str] = {}
+ for img_url in image_urls:
+ try:
+ result_json = await vision_analyze_tool(
+ image_url=img_url,
+ user_prompt=analysis_prompt,
+ )
+ result = json.loads(result_json)
+ if result.get("success"):
+ desc = result.get("analysis", "")
+ url_to_desc[img_url] = (
+ f"[The user sent an image~ Here's what I can see:\n{desc}]\n"
+ f"[If you need a closer look, use vision_analyze with image_url: {img_url} ~]"
+ )
+ else:
+ url_to_desc[img_url] = (
+ "[The user sent an image but I couldn't quite see it this time (>_<) "
+ f"You can try looking at it yourself with vision_analyze using image_url: {img_url}]"
+ )
+ except Exception as e:
+ logger.error("Vision auto-analysis error for image %s: %s", img_url, e)
+ url_to_desc[img_url] = (
+ f"[The user sent an image but something went wrong when I tried to look at it~ "
+ f"You can try examining it yourself with vision_analyze using image_url: {img_url}]"
+ )
+
+ # Now replace image parts with text in a copy of messages
+ enriched_messages: List[Dict[str, Any]] = []
+ for msg in messages:
+ msg_copy = dict(msg)
+ content = msg_copy.get("content")
+ if isinstance(content, str):
+ enriched_messages.append(msg_copy)
+ continue
+
+ if not isinstance(content, list):
+ enriched_messages.append(msg_copy)
+ continue
+
+ enriched_parts: List[Any] = []
+ for part in content:
+ if not isinstance(part, dict):
+ enriched_parts.append(part)
+ continue
+
+ part_type = str(part.get("type", "")).strip().lower()
+ if part_type in {"image_url", "input_image"}:
+ img_ref = part.get("image_url")
+ if isinstance(img_ref, dict):
+ img_url = img_ref.get("url")
+ else:
+ img_url = img_ref
+ if isinstance(img_url, str) and img_url in url_to_desc:
+ # Replace image part with text part
+ enriched_parts.append({
+ "type": "text",
+ "text": url_to_desc[img_url],
+ })
+ else:
+ # Keep original if URL not found (shouldn't happen)
+ enriched_parts.append(part)
+ else:
+ enriched_parts.append(part)
+
+ msg_copy["content"] = enriched_parts
+ enriched_messages.append(msg_copy)
+
+ # Return modified body
+ body_copy = dict(body)
+ body_copy["messages"] = enriched_messages
+ return body_copy
+
+
def _session_chat_user_message(body: Dict[str, Any], *, param: str = "message") -> tuple[Any, Optional["web.Response"]]:
"""Parse and normalize session chat ``message`` / ``input`` like chat completions."""
user_message = body.get("message") or body.get("input")