Merge verified Odysseus fixes

This commit is contained in:
pewdiepie-archdaemon
2026-07-23 14:49:02 +00:00
parent 4c9a8ca115
commit d8a2059df8
117 changed files with 15693 additions and 3191 deletions
+327 -5
View File
@@ -19,7 +19,7 @@ import json
import logging
import uuid
import time
from typing import Dict, Optional, Tuple
from typing import Any, Awaitable, Callable, Dict, Optional, Tuple
from src.constants import GENERATED_IMAGES_DIR
@@ -71,9 +71,10 @@ def set_rag_manager(rag_mgr, personal_docs_mgr=None):
# ---------------------------------------------------------------------------
from src.endpoint_resolver import build_chat_url, build_headers, build_models_url, resolve_endpoint_runtime
from src.image_model_ids import looks_like_image_generation_model, model_id_leaf
def _resolve_model(spec: str, owner: Optional[str] = None) -> Tuple[str, str, Dict]:
def _resolve_model(spec: str, owner: Optional[str] = None, model_type: Optional[str] = None) -> Tuple[str, str, Dict]:
"""Resolve a model specifier to (endpoint_url, model_id, headers).
Accepts:
@@ -97,9 +98,29 @@ def _resolve_model(spec: str, owner: Optional[str] = None) -> Tuple[str, str, Di
else:
model_name = spec
def _json_list(value) -> list[str]:
try:
data = json.loads(value or "[]")
except Exception:
return []
if not isinstance(data, list):
return []
return [str(x) for x in data if isinstance(x, (str, int, float)) and str(x)]
def _image_like(name: str) -> bool:
n = (name or "").lower()
if looks_like_image_generation_model(n):
return True
return any(k in n for k in (
"qwen-image", "qwen/image", "z-image", "flux", "stable-diffusion",
"sdxl", "hidream", "boogu", "krea-2", "image-edit",
))
db = SessionLocal()
try:
query = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
if model_type:
query = query.filter(ModelEndpoint.model_type == model_type)
if target_endpoint_name:
query = query.filter(ModelEndpoint.name.ilike(f"%{target_endpoint_name}%"))
if owner:
@@ -129,11 +150,13 @@ def _resolve_model(spec: str, owner: Optional[str] = None) -> Tuple[str, str, Di
return build_chat_url(base), matched, headers
else:
# OpenAI-compatible and native Ollama: probe the provider's model list.
endpoint_reachable = False
try:
models_url = build_models_url(base)
if models_url:
r = httpx.get(models_url, headers=headers, timeout=5)
r.raise_for_status()
endpoint_reachable = True
data = r.json()
items = data if isinstance(data, list) else (data.get("data") or [])
model_ids = [m.get("id") for m in items if isinstance(m, dict) and m.get("id")]
@@ -144,10 +167,21 @@ def _resolve_model(spec: str, owner: Optional[str] = None) -> Tuple[str, str, Di
if m.get("name") or m.get("model")
]
else:
endpoint_reachable = True
model_ids = json.loads(ep.cached_models or "[]")
except Exception:
model_ids = []
# Manual/local image endpoints are often registered with pinned
# model ids, while /models may return a runtime alias or only the
# served internal id. Include pinned/cached ids in the match set
# so chat sessions using the HF repo id still resolve. Do not use
# stale cached aliases when the endpoint itself is unreachable.
if model_type == "image" and endpoint_reachable:
for extra in _json_list(getattr(ep, "pinned_models", None)) + _json_list(getattr(ep, "cached_models", None)):
if extra not in model_ids:
model_ids.append(extra)
# Exact match first
for mid in model_ids:
if mid.lower() == model_name.lower():
@@ -158,6 +192,13 @@ def _resolve_model(spec: str, owner: Optional[str] = None) -> Tuple[str, str, Di
if model_name.lower() in mid.lower() or mid.lower() in model_name.lower():
return build_chat_url(base), mid, headers
# Last resort for local image endpoints: if the requested model
# name is clearly an image model, use the endpoint's first known
# image model id. This prevents a harmless alias mismatch from
# blocking image generation.
if model_type == "image" and _image_like(model_name) and model_ids:
return build_chat_url(base), model_ids[0], headers
raise ValueError(f"Model '{spec}' not found on any configured endpoint")
finally:
db.close()
@@ -967,16 +1008,36 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
if not model_spec:
return {"error": "No image model found. Configure one in Admin → Image Generation."}
async def _resolve_image_model(model_name: str):
def _call():
try:
return _resolve_model(model_name, owner=owner, model_type="image")
except TypeError as exc:
if "model_type" not in str(exc):
raise
return _resolve_model(model_name, owner=owner)
return await asyncio.to_thread(_call)
# Resolve the model to find the right endpoint
try:
url, model_id, headers = await asyncio.to_thread(_resolve_model, model_spec, owner=owner)
try:
url, model_id, headers = await _resolve_image_model(model_spec)
except ValueError:
_lower_model_spec = model_spec.lower()
if not (
any(_name in _lower_model_spec for _name in ("gpt-image", "dall-e"))
or looks_like_image_generation_model(_lower_model_spec)
):
raise
url, model_id, headers = await asyncio.to_thread(_resolve_model, model_spec, owner=owner)
except ValueError:
return {"error": f"No endpoint found with image model '{model_spec}'. "
"Configure an OpenAI-compatible endpoint with image generation support."}
# Detect if this is a GPT image model vs DALL-E vs local diffusion
is_gpt_image = "gpt-image" in model_id.lower()
is_dalle = "dall-e" in model_id.lower()
_model_leaf = model_id_leaf(model_id)
is_gpt_image = _model_leaf.startswith("gpt-image") or (_model_leaf.startswith("gpt-") and "-image" in _model_leaf)
is_dalle = _model_leaf.startswith("dall-e")
is_local_diffusion = not is_gpt_image and not is_dalle
# Build the images endpoint URL from the chat completions URL
@@ -1106,6 +1167,267 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
return {"error": f"Image generation error: {str(e)}"}
async def do_edit_image(
prompt: str,
image_path: str,
model_spec: str = "",
session_id: Optional[str] = None,
owner: Optional[str] = None,
size: str = "1024x1024",
quality: str = "medium",
progress_callback: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None,
) -> Dict:
"""Edit an uploaded image using the configured image endpoint."""
import base64
import httpx
import mimetypes
import os
from pathlib import Path
from src.url_safety import check_outbound_url
prompt = (prompt or "").strip()
if not prompt:
return {"error": "Image edit prompt is required"}
path = Path(image_path)
if not path.exists() or not path.is_file():
return {"error": "Attached image file was not found"}
try:
from src.settings import load_settings
_settings = load_settings()
except Exception:
_settings = {}
if not model_spec:
model_spec = _settings.get("image_model", "")
if quality == "medium" and _settings.get("image_quality"):
quality = _settings["image_quality"]
if not model_spec:
return {"error": "No image model selected for image editing"}
try:
try:
def _call():
try:
return _resolve_model(model_spec, owner=owner, model_type="image")
except TypeError as exc:
if "model_type" not in str(exc):
raise
return _resolve_model(model_spec, owner=owner)
url, model_id, headers = await asyncio.to_thread(_call)
except ValueError:
url, model_id, headers = await asyncio.to_thread(_resolve_model, model_spec, owner=owner)
except ValueError:
return {"error": f"No endpoint found with image model '{model_spec}'."}
base_url = url.replace("/chat/completions", "").replace("/v1/messages", "").rstrip("/")
edits_url = base_url + "/images/edits"
mime = mimetypes.guess_type(str(path))[0] or "image/png"
payload = {
"model": model_id,
"prompt": prompt,
"n": "1",
"size": size,
"quality": quality if quality in ("low", "medium", "high", "auto") else "medium",
"response_format": "b64_json",
}
request_id = uuid.uuid4().hex
payload["request_id"] = request_id
logger.info("Image edit: model=%s, size=%s, quality=%s, image=%s, prompt=%s", model_id, size, quality, path.name, prompt[:80])
def _save_edited_image_to_gallery(filename: str) -> str:
try:
from src.database import SessionLocal as _GallerySL, GalleryImage
new_id = str(uuid.uuid4())
_gdb = _GallerySL()
_gdb.add(GalleryImage(
id=new_id,
filename=filename,
prompt=prompt,
model=model_id,
size=size,
quality=payload.get("quality", "medium"),
session_id=session_id,
owner=owner,
))
_gdb.commit()
_gdb.close()
return new_id
except Exception as _ge:
logger.warning("Failed to save edited image gallery record: %s", _ge)
return ""
def _save_image_bytes(image_bytes: bytes, suffix: str = ".png") -> tuple[str, str]:
img_dir = Path(GENERATED_IMAGES_DIR)
img_dir.mkdir(parents=True, exist_ok=True)
filename = f"{uuid.uuid4().hex[:12]}{suffix}"
(img_dir / filename).write_bytes(image_bytes)
return f"/api/generated-image/{filename}", _save_edited_image_to_gallery(filename)
async def _try_local_img2img_fallback(client: httpx.AsyncClient) -> Optional[Dict[str, Any]]:
"""Try Odysseus' local diffusion img2img endpoint.
Some self-hosted SD/SDXL endpoints expose text-to-image plus
`/images/harmonize`/img2img, but not OpenAI's multipart
`/images/edits`. For chat uploads ("image + prompt"), this gives the
expected instruction-edit behavior instead of stopping at a 400.
"""
harmonize_url = base_url + "/images/harmonize"
try:
image_bytes = path.read_bytes()
image_b64 = base64.b64encode(image_bytes).decode()
fallback_payload = {
"image": image_b64,
"prompt": prompt,
"strength": 0.35,
"steps": 0,
"max_side": 1024,
}
if progress_callback:
await progress_callback({
"status": "running",
"message": "Trying image-to-image fallback",
"step": 0,
"total": 0,
})
fallback_resp = await client.post(harmonize_url, json=fallback_payload, headers=headers)
if fallback_resp.status_code == 404:
return None
if fallback_resp.status_code != 200:
error_text = fallback_resp.text[:500]
try:
err_json = fallback_resp.json()
error_text = err_json.get("detail") or err_json.get("error") or error_text
except Exception:
pass
return {"error": f"Image edit fallback failed ({fallback_resp.status_code}): {error_text}"}
fallback_data = fallback_resp.json()
image_b64 = fallback_data.get("image")
if not image_b64:
return {"error": "Image edit fallback returned no image"}
image_url, image_id = _save_image_bytes(base64.b64decode(image_b64))
return {
"results": f"Edited image for: {prompt[:100]}",
"image_url": image_url,
"image_id": image_id,
"image_prompt": prompt,
"image_model": model_id,
"image_size": size,
"image_quality": payload.get("quality", "medium"),
"edit_route": "img2img",
}
except httpx.TimeoutException:
return {"error": "Image edit fallback timed out. The model may still be loading or overloaded."}
except Exception as fallback_error:
logger.warning("Image edit fallback failed: %s", fallback_error)
return {"error": f"Image edit fallback error: {fallback_error}"}
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(connect=30.0, read=600.0, write=60.0, pool=30.0)) as client:
progress_task = None
if progress_callback:
progress_url = base_url + f"/images/progress/{request_id}"
async def _poll_progress():
last_sig = None
while True:
try:
pr = await client.get(progress_url, headers=headers, timeout=5.0)
if pr.status_code == 404:
return
if pr.status_code == 200:
data = pr.json()
sig = (data.get("status"), data.get("step"), data.get("total"), data.get("percent"))
if sig != last_sig:
last_sig = sig
await progress_callback(data)
if data.get("status") in {"done", "error"}:
return
except Exception:
return
await asyncio.sleep(1)
progress_task = asyncio.create_task(_poll_progress())
try:
with path.open("rb") as f:
files = {"image": (path.name, f, mime)}
resp = await client.post(edits_url, data=payload, files=files, headers=headers)
finally:
if progress_task:
progress_task.cancel()
try:
await progress_task
except asyncio.CancelledError:
pass
if resp.status_code != 200:
error_text = resp.text[:500]
try:
err_json = resp.json()
err = err_json.get("error")
error_text = (
err.get("message", error_text)
if isinstance(err, dict)
else str(err or err_json.get("detail") or error_text)
)
except Exception:
pass
if resp.status_code in (400, 404, 405, 422):
fallback = await _try_local_img2img_fallback(client)
if fallback:
return fallback
if resp.status_code == 404:
return {
"error": (
f"Image model '{model_id}' is reachable, but this endpoint does not expose image editing. "
"Use it without an attached image for text-to-image generation, or serve an edit/img2img "
"model for attached-image prompts."
)
}
return {"error": f"Image edit failed ({resp.status_code}): {error_text}"}
data = resp.json()
images = data.get("data", [])
if not images:
return {"error": "No image returned from edit API"}
img = images[0]
image_url = None
image_id = None
if img.get("b64_json"):
image_url, image_id = _save_image_bytes(base64.b64decode(img.get("b64_json")))
elif img.get("url"):
result_url = img["url"]
ok, reason = check_outbound_url(
result_url,
block_private=os.getenv("IMAGE_BLOCK_PRIVATE_IPS", "false").lower() == "true",
)
if not ok:
return {"error": f"Image edit API returned unsafe image URL: {reason}"}
dl_resp = httpx.get(result_url, timeout=60)
if dl_resp.status_code != 200:
return {"error": f"Could not download edited image ({dl_resp.status_code})"}
image_url, image_id = _save_image_bytes(dl_resp.content)
else:
return {"error": "Image edit API returned unexpected format (no b64_json or url)"}
return {
"results": f"Edited image for: {prompt[:100]}",
"image_url": image_url,
"image_id": image_id,
"image_prompt": prompt,
"image_model": model_id,
"image_size": size,
"image_quality": payload.get("quality", "medium"),
}
except httpx.TimeoutException:
return {"error": "Image edit timed out. The model may still be loading or overloaded."}
except Exception as e:
return {"error": f"Image edit error: {str(e)}"}
# ---------------------------------------------------------------------------
# Dispatcher (called from agent_tools.execute_tool_block)
# ---------------------------------------------------------------------------