Merge commit 'refs/phase3/pre-ajax/publication-tip' into integration/pre-ajax-release

# Conflicts:
#	routes/chat_routes.py
#	routes/session_routes.py
#	src/agent_loop.py
#	src/agent_tools/filesystem_tools.py
#	src/teacher_escalation.py
#	src/tool_capabilities.py
#	src/tool_execution.py
#	tests/test_mcp_add_server_args_validation.py
#	tests/test_token_cache_atomic_swap.py
This commit is contained in:
Alexandre Teixeira
2026-10-05 15:59:59 +01:00
1395 changed files with 360455 additions and 105938 deletions
+305 -35
View File
@@ -17,6 +17,7 @@ through the standard agent_tools.py pipeline.
import asyncio
import json
import logging
import re
import uuid
import time
from typing import Any, Awaitable, Callable, Dict, Optional, Tuple
@@ -338,6 +339,44 @@ async def do_pipeline(content: str, session_id: Optional[str] = None, owner: Opt
# Memory management tool
# ---------------------------------------------------------------------------
def _manage_memory_lines(content: str) -> list[str]:
"""Normalize the public JSON contract to the legacy line protocol."""
raw_content = content.strip()
if not raw_content.startswith("{"):
return raw_content.split("\n")
try:
payload = json.loads(raw_content)
except (TypeError, json.JSONDecodeError):
return raw_content.split("\n")
if not isinstance(payload, dict):
return raw_content.split("\n")
action = str(payload.get("action") or "").strip().lower()
command = payload.get("command")
if not action and isinstance(command, str) and command.strip():
return command.strip().split("\n")
command_lines: list[str] = []
if isinstance(command, str) and command.strip():
command_lines = command.strip().split("\n")
if command_lines and command_lines[0].strip().lower() == action:
command_lines = command_lines[1:]
if action == "list":
return [action, str(payload.get("category") or "")]
if action == "add":
text = payload.get("text") or (command_lines[0] if command_lines else "")
category = payload.get("category") or (command_lines[1] if len(command_lines) > 1 else "fact")
return [action, str(text), str(category)]
if action == "edit":
memory_id = payload.get("memory_id") or payload.get("id") or (command_lines[0] if command_lines else "")
text = payload.get("text") or ("\n".join(command_lines[1:]) if len(command_lines) > 1 else "")
return [action, str(memory_id), str(text)]
if action == "delete":
memory_id = payload.get("memory_id") or payload.get("id") or (command_lines[0] if command_lines else "")
return [action, str(memory_id)]
if action == "search":
query = payload.get("text") or payload.get("query") or "\n".join(command_lines)
return [action, str(query)]
return [action] if action else raw_content.split("\n")
async def do_manage_memory(content: str, session_id: Optional[str] = None, owner: Optional[str] = None) -> Dict:
"""Manage memories: list, add, edit, delete, search.
@@ -355,7 +394,12 @@ async def do_manage_memory(content: str, session_id: Optional[str] = None, owner
if not _memory_manager:
return {"error": "Memory manager not available"}
lines = content.strip().split("\n")
from src.agent_runtime.owned_resources import active_owned_operation
bound = active_owned_operation()
if bound is not None:
bound.validate()
lines = _manage_memory_lines(content)
if not lines:
return {"error": "Need at least 1 line: action"}
@@ -428,7 +472,7 @@ async def do_manage_memory(content: str, session_id: Optional[str] = None, owner
memories = _memory_manager.load_all()
found = False
for m in memories:
if m.get("id", "").startswith(memory_id):
if (m.get("id", "") == memory_id if bound is not None else m.get("id", "").startswith(memory_id)):
# Verify ownership
if owner and m.get("owner") != owner:
return {"error": f"Memory '{memory_id}' not found"}
@@ -463,7 +507,7 @@ async def do_manage_memory(content: str, session_id: Optional[str] = None, owner
full_id = None
delete_id = None
for m in memories:
if m.get("id", "").startswith(memory_id):
if (m.get("id", "") == memory_id if bound is not None else m.get("id", "").startswith(memory_id)):
# Verify ownership
if owner and m.get("owner") != owner:
return {"error": f"Memory '{memory_id}' not found"}
@@ -493,7 +537,16 @@ async def do_manage_memory(content: str, session_id: Optional[str] = None, owner
query_lower = query.lower()
exact_results = [m for m in memories if query_lower in (m.get("text", "").lower())]
if hasattr(_memory_manager, 'get_relevant_memories'):
# An exact marker is commonly used to identify one record for an
# edit/delete workflow. Do not mix fuzzy neighbors into that result:
# a semantically related record must never be mistaken for the exact
# target of a destructive operation.
exact_marker_query = bool(
re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{12,}", query)
)
if exact_results or exact_marker_query:
vector_results = []
elif hasattr(_memory_manager, 'get_relevant_memories'):
vector_results = _memory_manager.get_relevant_memories(query, memories, threshold=0.05, max_items=20)
else:
vector_results = []
@@ -508,6 +561,29 @@ async def do_manage_memory(content: str, session_id: Optional[str] = None, owner
if len(results) >= 20:
break
# Keep a lexical safety net for short preference/identity queries. A
# vector-only top-k result can bury an exact fact such as "Maya uses
# Pacific time" beneath several related Maya fixture facts.
if not exact_results and not exact_marker_query and len(results) < 20:
query_terms = {
token for token in re.findall(r"[a-z0-9]+", query_lower)
if len(token) >= 4 and token not in {"what", "does", "with", "saved", "memory", "search", "look"}
}
lexical = []
for memory in memories:
text_lower = str(memory.get("text") or "").lower()
overlap = sum(1 for token in query_terms if token in text_lower)
if overlap:
lexical.append((overlap, memory))
for _, memory in sorted(lexical, key=lambda item: (-item[0], str(item[1].get("id", "")))):
mid = memory.get("id")
if mid in seen:
continue
seen.add(mid)
results.append(memory)
if len(results) >= 20:
break
if not results:
return {"results": f"No memories found matching '{query}'."}
result_lines = [f"Found {len(results)} matching memories:\n"]
@@ -628,9 +704,10 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
toggle <name> <on|off> — Toggle a setting (web, bash, rag, research, incognito, document_editor)
set_mode <agent|chat> — Switch between agent and chat mode
switch_model <model> — Change the model for the current session
set_theme <preset> — Apply a built-in theme preset (dark, light, midnight, paper, cyberpunk, retrowave, forest, ocean, ume, copper, terminal, organs, lavender, gpt, claude, cute)
set_theme <preset> — Apply a built-in theme preset (dark, light, midnight, cyberpunk, retrowave, forest, ocean, ume, terminal, organs, gpt, claude, cute, eclipse, porcelain, arcade, blueprint, monolith, yoyo)
create_theme <name> <bg> <fg> <panel> <border> <accent> [key=val ...] — Create custom theme. Optional key=val: advanced color overrides AND background effects: bgPattern=<none|dots|synapse|rain|constellations|perlin-flow|petals|sparkles|embers>, bgEffectColor=#RRGGBB, bgEffectIntensity=<num>, bgEffectSize=<num>, frosted=true|false
open_panel <name> — Open a panel (documents, gallery, email, sessions, notes, memories, skills, settings, cookbook)
get_theme — Return the last server-synchronized theme for this user
open_panel <name> [view] — Open a panel; Cookbook views are download/models, launch/serve, active/running, dependencies, settings
open_email_reply <uid> [folder] [reply|reply-all|ai-reply] [body text] — Open a reply draft document for an email; does not send. ALWAYS append the body text when the user told you what to say (one-shot draft); only omit body when the user just asked to "open a reply" without content.
get_toggles — Return current toggle states (server-side knowledge)
"""
@@ -638,6 +715,16 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
if not lines:
return {"error": "No action specified"}
theme_args = None
if content.lstrip().startswith('{'):
import json
try:
theme_args = json.loads(content)
except ValueError:
return {"error": "Invalid UI action JSON."}
if not isinstance(theme_args, dict) or theme_args.get('action') != 'create_theme':
return {"error": "Structured UI action must be create_theme."}
lines = ['create_theme']
parts = lines[0].strip().split(None, 2)
action = parts[0].lower()
@@ -724,26 +811,41 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
}
elif action == "set_theme":
theme_name = parts[1].lower() if len(parts) > 1 else ""
theme_name = content.strip().partition(' ')[2].strip().lower().replace(' ', '-')
# Theme colors are defined in static/js/theme.js on the frontend.
# We pass the name; the frontend looks it up from presets + custom themes.
# Also check user's custom themes stored in prefs.
# Must match the THEMES keys in static/js/theme.js.
known_presets = [
"dark", "light", "midnight", "paper", "cyberpunk", "retrowave",
"forest", "ocean", "ume", "copper", "terminal", "organs",
"lavender", "gpt", "claude", "cute",
]
from src.theme_palette import THEME_PRESETS
known_presets = THEME_PRESETS
custom_themes = {}
try:
from routes.prefs_routes import _load as _load_prefs
custom_themes = _load_prefs().get("custom-themes", {}) or {}
from routes.prefs_routes import _load_for_user
custom_themes = _load_for_user(owner).get("custom-themes", {}) or {}
except Exception:
pass
all_known = set(known_presets) | set(custom_themes.keys())
if theme_name not in all_known:
custom_label = f" | Custom: {', '.join(sorted(custom_themes.keys()))}" if custom_themes else ""
return {"error": f"Unknown theme '{theme_name}'. Available: {', '.join(sorted(known_presets))}{custom_label}"}
try:
from routes.prefs_routes import _load_for_user, _save_for_user
prefs = _load_for_user(owner)
previous = prefs.get("theme") if isinstance(prefs.get("theme"), dict) else {}
stored = {"name": theme_name}
if previous.get("name") == theme_name and isinstance(previous.get("colors"), dict):
stored["colors"] = previous["colors"]
elif isinstance(custom_themes.get(theme_name), dict):
stored["colors"] = custom_themes[theme_name]
theme_source = custom_themes.get(theme_name)
if not theme_source and previous.get('name') == theme_name:
theme_source = previous
if isinstance(theme_source, dict):
stored.update({k: v for k, v in theme_source.items() if k.startswith('bgEffect') or k in ('bgPattern', 'frosted')})
prefs["theme"] = stored
_save_for_user(owner, prefs)
except Exception:
pass
return {
"ui_event": "set_theme",
"theme_name": theme_name,
@@ -751,8 +853,24 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
}
elif action == "create_theme":
# Re-split without limit to get all parts
parts = lines[0].strip().split()
import shlex
try:
if theme_args is not None:
from src.theme_palette import normalize_theme_colors
palette = normalize_theme_colors(theme_args.get('colors'))
theme_name = theme_args.get('name')
if not isinstance(theme_name, str) or not theme_name.strip():
return {"error": "name must be a nonempty theme name."}
base = ('bg', 'fg', 'panel', 'border', 'accent')
parts = ['create_theme', theme_name.strip(), *(palette[k] for k in base)]
parts.extend(f'{k}={v}' for k, v in palette.items() if k not in base)
from src.theme_palette import normalize_theme_background
background = normalize_theme_background(theme_args.get('background'), palette['accent'])
parts.extend(f'{k}={v}' for k, v in background.items())
else:
parts = shlex.split(content.strip())
except ValueError as exc:
return {"error": f"Invalid theme arguments: {exc}"}
# create_theme <name> <bg> <fg> <panel> <border> <accent> [key=value ...]
if len(parts) < 7:
return {"error": "create_theme needs: create_theme <name> <bg> <fg> <panel> <border> <accent> (all hex colors). Optional advanced color key=value pairs (userBubbleBg, aiBubbleBg, bubbleBorder, sidebarBg, sectionAccent, brandColor, inputBg, inputBorder, sendBtnBg, sendBtnHover, codeBg, codeFg, toggleBg, toggleActive, accentPrimary, accentError). Optional background EFFECTS: bgPattern=<none|dots|synapse|rain|constellations|perlin-flow|petals|sparkles|embers>, bgEffectColor=#RRGGBB, bgEffectIntensity=<num e.g. 1>, bgEffectSize=<num e.g. 1>, frosted=true|false"}
@@ -774,7 +892,8 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
# Background-effect fields (animated pattern + frosted glass). Different
# value types than the hex-only advanced keys, so parse separately.
_BG_PATTERNS = {"none", "dots", "synapse", "rain", "constellations",
"perlin-flow", "petals", "sparkles", "embers"}
"perlin-flow", "petals", "sparkles", "embers",
"starfield-depth", "ascii-fireflies"}
bg = {}
for part in parts[7:]:
if "=" not in part:
@@ -792,15 +911,30 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
if not _re.match(r'^#[0-9a-fA-F]{6}$', av):
return {"error": f"Invalid hex color for bgEffectColor: '{av}'. Use format #RRGGBB"}
bg["effectColor"] = av
elif ak in ("bgEffectIntensity", "bgEffectSize"):
elif ak in ("bgEffectIntensity", "bgEffectSize", "bgEffectSpeed"):
try:
bg["effectIntensity" if ak == "bgEffectIntensity" else "effectSize"] = float(av)
bg[ak[2].lower() + ak[3:]] = float(av)
except ValueError:
return {"error": f"Invalid number for {ak}: '{av}'"}
elif ak == "frosted":
bg["frosted"] = av.lower() in ("true", "1", "yes", "on")
if advanced:
colors["advanced"] = advanced
try:
from routes.prefs_routes import _load_for_user, _save_for_user
prefs = _load_for_user(owner)
custom_themes = prefs.get("custom-themes")
custom_themes = dict(custom_themes) if isinstance(custom_themes, dict) else {}
custom_themes[name] = dict(colors)
prefs["custom-themes"] = custom_themes
prefs["theme"] = {"name": name, "colors": dict(colors)}
for key, value in bg.items():
stored_key = 'frosted' if key == 'frosted' else 'bg' + key[0].upper() + key[1:]
custom_themes[name][stored_key] = value
prefs['theme'][stored_key] = value
_save_for_user(owner, prefs)
except Exception:
return {"error": "Could not save the theme. No theme change was applied; retry when preferences storage is available."}
return {
"ui_event": "create_theme",
"theme_name": name,
@@ -831,8 +965,11 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
elif action == "open_panel":
# Open a top-level panel/modal: documents/library, gallery,
# email, sessions, notes, memories, skills, settings, cookbook.
# calendar, email, sessions, notes, memories, skills, settings, theme, cookbook.
panel = parts[1].lower() if len(parts) > 1 else ""
view = ""
view_label = ""
target_date = ""
_panel_aliases = {
"documents": "documents",
"document": "documents",
@@ -842,6 +979,9 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
"doclib": "documents",
"gallery": "gallery",
"images": "gallery",
"calendar": "calendar",
"cal": "calendar",
"schedule": "calendar",
"email": "email",
"emails": "email",
"inbox": "email",
@@ -859,6 +999,9 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
"skills": "skills",
"settings": "settings",
"preferences": "settings",
"theme": "theme",
"themes": "theme",
"appearance": "theme",
"cookbook": "cookbook",
"models": "cookbook",
"llm": "cookbook",
@@ -867,12 +1010,55 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
}
target = _panel_aliases.get(panel)
if not target:
return {"error": f"Unknown panel '{panel}'. Valid: documents, gallery, email, sessions, notes, memories, skills, settings, cookbook."}
return {
return {"error": f"Unknown panel '{panel}'. Valid: documents, gallery, calendar, email, sessions, notes, memories, skills, settings, theme, cookbook."}
if target == "cookbook":
cookbook_views = {
"models": ("Search", "models"), "model": ("Search", "models"),
"download": ("Search", "models"), "search": ("Search", "models"),
"serve": ("Serve", "launch"), "serving": ("Serve", "launch"),
"launch": ("Serve", "launch"),
"active": ("Running", "running"), "running": ("Running", "running"),
"dependencies": ("Dependencies", "dependencies"),
"dependency": ("Dependencies", "dependencies"),
"settings": ("Settings", "settings"),
}
requested_view = parts[2].strip().lower() if len(parts) > 2 else ""
# A panel alias can carry the subview intent by itself. Previously
# `models` and `serve` were silently collapsed to bare Cookbook.
resolved_view = cookbook_views.get(requested_view) or cookbook_views.get(panel)
if resolved_view:
view, view_label = resolved_view
if target == "calendar":
view_words = {"day", "week", "month", "year", "agenda"}
tail_text = ""
if len(parts) > 2:
tail_text = parts[2]
if len(lines) > 1:
tail_text = " ".join(p for p in [tail_text, " ".join(line.strip() for line in lines[1:] if line.strip())] if p)
tail = [p.strip().lower() for p in tail_text.split() if p.strip()]
for i, token in enumerate(tail):
if token in view_words:
view = token
target_date = " ".join(t for t in tail[i + 1:] if t != "view").strip()
break
if not view and tail and tail[0] in view_words:
view = tail[0]
target_date = " ".join(tail[1:]).strip()
payload = {
"ui_event": "open_panel",
"panel": target,
"results": f"Opening {target} panel",
}
if panel != target:
payload["requested_panel"] = panel
if view:
payload["view"] = view
if view_label:
payload["view_label"] = view_label
payload["results"] = f"Opening {target} panel in {view_label or view} view"
if target_date:
payload["target_date"] = target_date
return payload
elif action == "open_email_reply":
# Two forms supported:
@@ -924,6 +1110,34 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
result["body"] = body
return result
elif action == "get_theme":
from src.theme_palette import BACKGROUND_PATTERNS, THEME_PRESETS
prefs = {}
try:
from routes.prefs_routes import _load_for_user
prefs = _load_for_user(owner)
saved = prefs.get("theme")
except Exception:
saved = None
available = {'presets': list(THEME_PRESETS),
'custom_themes': sorted((prefs.get('custom-themes') or {}).keys()),
'background_patterns': list(BACKGROUND_PATTERNS)}
name = str(saved.get("name") or "").strip() if isinstance(saved, dict) else ""
if not name:
return {
"results": "The current client theme has not been synchronized to the server.",
"theme_known": False,
**available,
}
return {
"results": f"Current theme: {name}",
"current_theme": name,
"theme_known": True,
'colors': saved.get('colors'),
'background': {k: v for k, v in saved.items() if k.startswith('bg') or k == 'frosted'},
**available,
}
elif action == "get_toggles":
return {
"results": (
@@ -934,7 +1148,7 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
}
else:
return {"error": f"Unknown action '{action}'. Use: toggle, set_mode, switch_model, set_theme, highlight, clear_highlight, get_toggles"}
return {"error": f"Unknown action '{action}'. Use: toggle, set_mode, switch_model, set_theme, create_theme, get_theme, highlight, clear_highlight, get_toggles"}
# ---------------------------------------------------------------------------
@@ -956,11 +1170,23 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
from pathlib import Path
from src.url_safety import check_outbound_url
lines = content.strip().split("\n")
prompt = lines[0].strip() if lines else ""
model_spec = lines[1].strip() if len(lines) > 1 and lines[1].strip() else ""
size = lines[2].strip() if len(lines) > 2 and lines[2].strip() else "1024x1024"
quality = lines[3].strip() if len(lines) > 3 and lines[3].strip() else "medium"
if content.lstrip().startswith('{'):
try:
args = json.loads(content)
except (TypeError, ValueError):
return {"error": "Image arguments must be a JSON object"}
if not isinstance(args, dict):
return {"error": "Image arguments must be a JSON object"}
prompt = str(args.get('prompt') or '').strip()
model_spec = str(args.get('model') or '').strip()
size = str(args.get('size') or '1024x1024')
quality = str(args.get('quality') or 'medium')
else:
lines = content.strip().split("\n")
prompt = lines[0].strip() if lines else ""
model_spec = lines[1].strip() if len(lines) > 1 and lines[1].strip() else ""
size = lines[2].strip() if len(lines) > 2 and lines[2].strip() else "1024x1024"
quality = lines[3].strip() if len(lines) > 3 and lines[3].strip() else "medium"
if not prompt:
return {"error": "Image prompt is required (line 1)"}
@@ -972,6 +1198,9 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
except Exception:
_settings = {}
if not _settings.get("image_gen_enabled", True):
return {"error": "Image generation is disabled by the administrator."}
# Use admin-configured model/quality if not specified by the tool call
if not model_spec:
model_spec = _settings.get("image_model", "")
@@ -1059,6 +1288,9 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
# Build the images endpoint URL from the chat completions URL
base_url = url.replace("/chat/completions", "").replace("/v1/messages", "").rstrip("/")
images_url = base_url + "/images/generations"
from src.model_capability_readers.base import detect_vendor
if detect_vendor(url) == "openrouter":
images_url = base_url + "/images"
# Validate size for cloud image models (local diffusion accepts any WxH)
valid_gpt_sizes = {"1024x1024", "1024x1536", "1536x1024", "auto"}
@@ -1195,7 +1427,7 @@ async def do_edit_image(
model_spec: str = "",
session_id: Optional[str] = None,
owner: Optional[str] = None,
size: str = "1024x1024",
size: str = "auto",
quality: str = "medium",
progress_callback: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None,
) -> Dict:
@@ -1242,8 +1474,23 @@ async def do_edit_image(
except ValueError:
return {"error": f"No endpoint found with image model '{model_spec}'."}
if not size or size == "auto":
from PIL import Image
from src.image_model_ids import image_edit_size
try:
with Image.open(path) as source:
width, height = source.size
# EXIF rotation changes the displayed portrait/landscape shape.
if source.getexif().get(274) in {5, 6, 7, 8}:
width, height = height, width
size = image_edit_size(model_id, width, height)
except (OSError, ValueError, Image.DecompressionBombError):
return {"error": "Could not read the attached image dimensions. Try a PNG, JPEG, or WebP image."}
base_url = url.replace("/chat/completions", "").replace("/v1/messages", "").rstrip("/")
edits_url = base_url + "/images/edits"
from src.model_capability_readers.base import detect_vendor
is_openrouter = detect_vendor(url) == "openrouter"
mime = mimetypes.guess_type(str(path))[0] or "image/png"
payload = {
"model": model_id,
@@ -1281,6 +1528,11 @@ async def do_edit_image(
return ""
def _save_image_bytes(image_bytes: bytes, suffix: str = ".png") -> tuple[str, str]:
nonlocal size
from io import BytesIO
from PIL import Image
with Image.open(BytesIO(image_bytes)) as output:
size = f"{output.width}x{output.height}"
img_dir = Path(GENERATED_IMAGES_DIR)
img_dir.mkdir(parents=True, exist_ok=True)
filename = f"{uuid.uuid4().hex[:12]}{suffix}"
@@ -1351,7 +1603,7 @@ async def do_edit_image(
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:
if progress_callback and not is_openrouter:
progress_url = base_url + f"/images/progress/{request_id}"
async def _poll_progress():
@@ -1375,9 +1627,27 @@ async def do_edit_image(
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)
if is_openrouter:
# OpenRouter's Image API uses JSON reference images for
# edits, not OpenAI's multipart /images/edits protocol.
image_b64 = base64.b64encode(path.read_bytes()).decode("ascii")
edit_payload = {
"model": model_id,
"prompt": prompt,
"n": 1,
"size": size,
"quality": payload["quality"],
"output_format": "png",
"input_references": [{
"type": "image_url",
"image_url": {"url": f"data:{mime};base64,{image_b64}"},
}],
}
resp = await client.post(base_url + "/images", json=edit_payload, headers=headers)
else:
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()
@@ -1398,14 +1668,14 @@ async def do_edit_image(
)
except Exception:
pass
if resp.status_code in (400, 404, 405, 422):
if not is_openrouter and 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. "
f"The configured endpoint returned 404 for image editing with '{model_id}'. "
"Use it without an attached image for text-to-image generation, or serve an edit/img2img "
"model for attached-image prompts."
)