mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-06 05:02:24 +01:00
Preserve preview harness, editor, email and task improvements
Snapshot current maintainer-preview application changes and regression fixtures for integration into lab. Excludes local runtime data, evaluation outputs and source backups. Focused Python regression selection: 140 passed; full suite not certified.
This commit is contained in:
@@ -81,6 +81,10 @@ class SetAdminRequest(BaseModel):
|
||||
is_admin: bool
|
||||
|
||||
|
||||
class ResetUserPasswordRequest(BaseModel):
|
||||
new_password: str
|
||||
|
||||
|
||||
class SetOpenRegistrationRequest(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
@@ -322,6 +326,20 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
raise HTTPException(409, "Username already taken")
|
||||
return {"ok": True}
|
||||
|
||||
@router.put("/users/{username}/password")
|
||||
async def reset_user_password(username: str, body: ResetUserPasswordRequest, request: Request):
|
||||
user = _get_current_user(request)
|
||||
if not user or not auth_manager.is_admin(user):
|
||||
raise HTTPException(403, "Admin only")
|
||||
if len(body.new_password) < PASSWORD_MIN_LENGTH:
|
||||
raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LENGTH} characters")
|
||||
if len(body.new_password.encode("utf-8")) > 72:
|
||||
raise HTTPException(400, "Password must be at most 72 UTF-8 bytes")
|
||||
ok = await asyncio.to_thread(auth_manager.reset_user_password, username, body.new_password, user)
|
||||
if not ok:
|
||||
raise HTTPException(403, "Password reset is only available for existing non-admin accounts")
|
||||
return {"ok": True}
|
||||
|
||||
@router.put("/users/{username}/privileges")
|
||||
async def update_user_privileges(username: str, request: Request):
|
||||
user = _get_current_user(request)
|
||||
|
||||
+48
-11
@@ -87,7 +87,7 @@ from src.model_profiles import (
|
||||
)
|
||||
from src.tool_execution import AgentExecutionBridge, bind_execution_bridge
|
||||
from src.turn_contract import (
|
||||
bind_turn_contract, preserve_bound_editor_selected_tools,
|
||||
FAMILY_TOOLS, bind_turn_contract, preserve_bound_editor_selected_tools,
|
||||
requested_capabilities, resolve_turn_contract,
|
||||
requests_independent_web_source, requires_external_web_verification,
|
||||
selected_tools_for_request,
|
||||
@@ -3061,10 +3061,23 @@ def setup_chat_routes(
|
||||
full_schema_route=(_effective_tool_schema_mode == "full"),
|
||||
)
|
||||
_turn_history = getattr(sess, "history", []) or []
|
||||
from src.turn_contract import corrected_browser_target
|
||||
_corrected_browser_target = corrected_browser_target(message, _turn_history)
|
||||
_turn_capabilities = requested_capabilities(
|
||||
message, _turn_history,
|
||||
active_document=bool(active_doc), workspace=bool(workspace),
|
||||
image_attachment=any(str(a.get('mime') or '').startswith('image/') for a in (ctx.preprocessed.attachment_meta or [])),
|
||||
) if _use_turn_contract else frozenset()
|
||||
if 'image_editing' in _turn_capabilities and ctx.preprocessed.attachment_meta:
|
||||
image_refs = ['odysseus://attachment/' + str(a['id']) for a in ctx.preprocessed.attachment_meta
|
||||
if a.get('id') and str(a.get('mime') or '').startswith('image/')]
|
||||
if image_refs:
|
||||
image_edit_context = {'role': 'system', 'content':
|
||||
'For the requested image edit, use edit_image with action=prompt and image_id set to the uploaded image reference: '
|
||||
+ ', '.join(image_refs) + '. Pass the requested changes as prompt. The backend sends the actual source pixels; a description or stock-image URL is not an edited image.'}
|
||||
ctx.messages.insert(0, image_edit_context)
|
||||
if foreground_policy.enabled:
|
||||
getattr(ctx, 'route_messages', ctx.messages).insert(0, dict(image_edit_context))
|
||||
if _use_turn_contract and _explicit_browser_intent:
|
||||
# Interactive navigation is already an unambiguous request for
|
||||
# the browser family. The lexical family classifier intentionally
|
||||
@@ -3086,6 +3099,12 @@ def setup_chat_routes(
|
||||
# happened to run earlier in the session.
|
||||
_turn_capabilities = frozenset({'search_browser'})
|
||||
_active_turn_capabilities = _turn_capabilities
|
||||
if _use_turn_contract and active_doc:
|
||||
# A visible, owner-checked editor is a turn capability even when
|
||||
# the request classifier focuses on another task or misses a
|
||||
# pasted revision request. This only offers permitted schemas;
|
||||
# it never requires or performs a document mutation.
|
||||
_turn_capabilities = _turn_capabilities | {"documents"}
|
||||
_clean_v3_preview = bool(_use_turn_contract and _clean_v3_route_requested)
|
||||
# requested_capabilities already inherits a typed, recently executed
|
||||
# family for referential follow-ups. Do not additionally union stale
|
||||
@@ -3240,7 +3259,6 @@ def setup_chat_routes(
|
||||
_privs = request.app.state.auth_manager.get_privileges(_user)
|
||||
if _privs:
|
||||
if not _privs.get("can_use_bash", True):
|
||||
from src.turn_contract import FAMILY_TOOLS
|
||||
disabled_tools.update(FAMILY_TOOLS["shell_files"])
|
||||
if not _privs.get("can_use_browser", True):
|
||||
disabled_tools.update(_BROWSER_MCP_TOOLS)
|
||||
@@ -3248,7 +3266,7 @@ def setup_chat_routes(
|
||||
if not _privs.get("can_use_documents", True):
|
||||
disabled_tools.update({"manage_documents", "create_document", "edit_document", "update_document", "suggest_document"})
|
||||
if not _privs.get("can_generate_images", True):
|
||||
disabled_tools.add("generate_image")
|
||||
disabled_tools.update({"generate_image", "edit_image"})
|
||||
if not _privs.get("can_manage_memory", True):
|
||||
disabled_tools.update({"manage_memory", "manage_skills"})
|
||||
if not _privs.get("can_use_research", True):
|
||||
@@ -3332,7 +3350,10 @@ def setup_chat_routes(
|
||||
}.issubset(disabled_tools),
|
||||
}
|
||||
_turn_contract = None
|
||||
if _use_turn_contract and chat_mode == "agent":
|
||||
# Image models execute directly, not through the text-agent inventory.
|
||||
# Keep the permission policy above, but do not apply routing omissions
|
||||
# as denials to this separate execution path.
|
||||
if _use_turn_contract and chat_mode == "agent" and not image_generation_session:
|
||||
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
|
||||
from src.tool_utils import get_mcp_manager
|
||||
from src.tool_security import blocked_tools_for_owner
|
||||
@@ -3444,11 +3465,15 @@ def setup_chat_routes(
|
||||
# substitute direct sending or document creation.
|
||||
_turn_capabilities = _turn_capabilities | {"ui"}
|
||||
_required_tools.add("ui_control")
|
||||
if _corrected_browser_target:
|
||||
_selected_tools = {'private_browser', 'web_fetch', 'web_search'}
|
||||
_required_tools = {'private_browser'}
|
||||
_turn_contract = resolve_turn_contract(
|
||||
capabilities=_turn_capabilities, schemas=_contract_schemas,
|
||||
policy=_contract_policy, required_tools=_required_tools,
|
||||
required_capabilities=_active_turn_capabilities,
|
||||
selected_tools=_selected_tools,
|
||||
always_available_tools=(FAMILY_TOOLS["documents"] if active_doc else ()),
|
||||
warm_tools=_warm_tools,
|
||||
message=message, history=getattr(sess, "history", []) or [],
|
||||
)
|
||||
@@ -3807,10 +3832,16 @@ def setup_chat_routes(
|
||||
yield f'data: {json.dumps(_model_info)}\n\n'
|
||||
|
||||
_terminal_saved = False
|
||||
if _is_image_generation_session(sess, owner=_user):
|
||||
if image_generation_session:
|
||||
from src.settings import get_setting
|
||||
if tool_policy.blocks("generate_image"):
|
||||
_blocked_msg = tool_policy.reason_for("generate_image")
|
||||
_image_upload = _first_image_attachment(chat_handler, att_ids, owner=_user)
|
||||
_image_tool_name = "edit_image" if _image_upload else "generate_image"
|
||||
_blocked_image_tool = next((
|
||||
name for name in dict.fromkeys(("generate_image", _image_tool_name))
|
||||
if tool_policy.blocks(name)
|
||||
), None)
|
||||
if _blocked_image_tool:
|
||||
_blocked_msg = tool_policy.reason_for(_blocked_image_tool)
|
||||
yield f'data: {json.dumps({"delta": _blocked_msg})}\n\n'
|
||||
yield "data: [DONE]\n\n"
|
||||
_active_streams.pop(session, None)
|
||||
@@ -3822,8 +3853,6 @@ def setup_chat_routes(
|
||||
return
|
||||
from src.ai_interaction import do_edit_image, do_generate_image
|
||||
_user_msg = message or ""
|
||||
_image_upload = _first_image_attachment(chat_handler, att_ids, owner=_user)
|
||||
_image_tool_name = "edit_image" if _image_upload else "generate_image"
|
||||
yield f'data: {json.dumps({"type": "tool_start", "tool": _image_tool_name, "command": _user_msg[:100]})}\n\n'
|
||||
yield ": heartbeat\n\n"
|
||||
_progress_queue: asyncio.Queue = asyncio.Queue()
|
||||
@@ -3841,7 +3870,7 @@ def setup_chat_routes(
|
||||
model_spec=sess.model,
|
||||
session_id=session,
|
||||
owner=_user,
|
||||
size="1024x1024",
|
||||
size="auto",
|
||||
progress_callback=_image_progress_callback,
|
||||
))
|
||||
else:
|
||||
@@ -4420,7 +4449,7 @@ def setup_chat_routes(
|
||||
elif data.get("type") in (
|
||||
"tool_start", "tool_output", "agent_step",
|
||||
"doc_stream_open", "doc_stream_delta",
|
||||
"doc_update", "doc_suggestions", "ui_control",
|
||||
"doc_update", "doc_suggestions", "editor_progress", "ui_control", "email_open",
|
||||
"rounds_exhausted", "budget_exceeded",
|
||||
"loop_breaker_triggered",
|
||||
"intent_nudge_exhausted",
|
||||
@@ -4763,6 +4792,14 @@ def setup_chat_routes(
|
||||
stopped = agent_runs.stop(session_id, _expected_run_id)
|
||||
return {"stopped": stopped}
|
||||
|
||||
@router.post("/api/chat/finish/{session_id}")
|
||||
async def chat_finish(request: Request, session_id: str) -> Dict[str, Any]:
|
||||
"""Finish an editor run without discarding completed tools or review cards."""
|
||||
_verify_session_owner(request, session_id)
|
||||
expected_run_id = request.headers.get("X-Odysseus-Run-Id")
|
||||
accepted = agent_runs.request_finish(session_id, expected_run_id)
|
||||
return {"accepted": accepted}
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# GET /api/chat/stream_status — check if a stream is active for a session
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@@ -1984,9 +1984,8 @@ _EMAIL_REPLY_SYS_PROMPT_BASE = (
|
||||
"<<<REPLY>>>\n"
|
||||
"(the reply body goes here)\n"
|
||||
"<<<END>>>\n"
|
||||
"Any reasoning, planning, or notes-to-self must come BEFORE the <<<REPLY>>> marker "
|
||||
"(ideally wrapped in <think>...</think>). Only the text between <<<REPLY>>> and <<<END>>> "
|
||||
"is sent as the email — nothing else is shown to anyone."
|
||||
"Start with <<<REPLY>>> immediately. Do not output reasoning, planning, or notes-to-self. "
|
||||
"Only the final reply belongs between <<<REPLY>>> and <<<END>>>."
|
||||
)
|
||||
|
||||
|
||||
|
||||
+52
-15
@@ -3768,6 +3768,11 @@ def setup_email_routes():
|
||||
full = True if full is True or str(full).lower() == "true" else False
|
||||
fixture_result = _fixture_email_read(uid, folder, owner)
|
||||
if fixture_result is not None:
|
||||
# require_owner validated the selected account. Fixture row labels
|
||||
# are not configured account IDs; keep the selection for AI Reply
|
||||
# and other subsequent account-scoped operations.
|
||||
if account_id and not fixture_result.get("error"):
|
||||
fixture_result = {**fixture_result, "account_id": account_id}
|
||||
return fixture_result
|
||||
ck = _read_cache_key(account_id, folder, uid, owner=owner) + (int(bool(full)),)
|
||||
cached = _read_cache_get(ck)
|
||||
@@ -6153,6 +6158,23 @@ def setup_email_routes():
|
||||
@router.post("/ai-reply")
|
||||
async def ai_reply(data: dict, owner: str = Depends(require_owner)):
|
||||
"""Generate an AI-drafted reply to an email using the user's writing style."""
|
||||
if data.get('stream'):
|
||||
async def events():
|
||||
queue = asyncio.Queue()
|
||||
async def run():
|
||||
result = await ai_reply({**data, 'stream': False, '_emit': queue.put}, owner)
|
||||
await queue.put({'type': 'result', **result})
|
||||
task = asyncio.create_task(run())
|
||||
try:
|
||||
while True:
|
||||
item = await queue.get()
|
||||
yield 'data: ' + json.dumps(item) + '\n\n'
|
||||
if item.get('type') == 'result':
|
||||
break
|
||||
finally:
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
return StreamingResponse(events(), media_type='text/event-stream', headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'})
|
||||
try:
|
||||
from src.endpoint_resolver import resolve_endpoint
|
||||
|
||||
@@ -6176,7 +6198,7 @@ def setup_email_routes():
|
||||
# Skip cache lookup when the caller supplied a user_hint — the
|
||||
# cached generic reply doesn't reflect the instructions and
|
||||
# would silently override them.
|
||||
if message_id and not user_hint and not account_id:
|
||||
if message_id and not user_hint and not account_id and not callable(data.get('_emit')):
|
||||
try:
|
||||
_c = _sql3.connect(SCHEDULED_DB)
|
||||
owner_clause, owner_params = _email_cache_owner_clause(owner)
|
||||
@@ -6276,7 +6298,7 @@ def setup_email_routes():
|
||||
# by exact id, then basename; fall back to the first served model.
|
||||
try:
|
||||
from src.llm_core import list_model_ids
|
||||
_avail = list_model_ids(url, headers=headers)
|
||||
_avail = await asyncio.to_thread(list_model_ids, url, headers=headers)
|
||||
if _avail and model not in _avail:
|
||||
import os as _os
|
||||
_base = _os.path.basename((model or "").rstrip("/"))
|
||||
@@ -6293,7 +6315,7 @@ def setup_email_routes():
|
||||
# Owner-scoped so pre-retrieval never crosses tenants.
|
||||
context_snippets, _terms = ([], [])
|
||||
if not fast_reply:
|
||||
context_snippets, _terms = _pre_retrieve_context(original_body, to, owner=owner)
|
||||
context_snippets, _terms = await asyncio.to_thread(_pre_retrieve_context, original_body, to, owner=owner)
|
||||
|
||||
# NEW: also pull the last few emails from the original sender +
|
||||
# their attachments. The "to" field on this endpoint is the
|
||||
@@ -6304,7 +6326,7 @@ def setup_email_routes():
|
||||
if not fast_reply:
|
||||
try:
|
||||
from_addr_for_ctx = email.utils.parseaddr(to or "")[1]
|
||||
referenced = _fetch_sender_thread_context(
|
||||
referenced = await asyncio.to_thread(_fetch_sender_thread_context,
|
||||
sender_addr=from_addr_for_ctx,
|
||||
exclude_uid=source_uid,
|
||||
exclude_folder=source_folder,
|
||||
@@ -6315,6 +6337,14 @@ def setup_email_routes():
|
||||
logger.warning(f"sender-thread-context failed: {_e}")
|
||||
|
||||
system_prompt = _EMAIL_REPLY_SYS_PROMPT_BASE
|
||||
config = await asyncio.to_thread(_get_email_config, account_id, owner=owner)
|
||||
mailbox = config.get('from_address') or config.get('imap_user') or ''
|
||||
system_prompt += (
|
||||
f'\n\nYou are writing FROM this mailbox: {mailbox}. '
|
||||
'The recipient is the person being replied to, not your identity. '
|
||||
'Quoted participants are third parties. Do not adopt their signatures or commitments. '
|
||||
'Return the final reply immediately inside <<<REPLY>>> and <<<END>>>; no analysis.'
|
||||
)
|
||||
if general_style:
|
||||
system_prompt += f"\n\nGENERAL WRITING STYLE:\n{general_style}"
|
||||
if style:
|
||||
@@ -6332,7 +6362,7 @@ def setup_email_routes():
|
||||
|
||||
user_msg = (
|
||||
f"Recipient: {to}\nSubject: {subject}\n\n"
|
||||
f"Original email and any current draft:\n{original_body[:6000]}\n\n"
|
||||
f"Received email and quoted conversation (not your draft):\n{original_body[:6000]}\n\n"
|
||||
)
|
||||
if user_hint:
|
||||
user_msg += (
|
||||
@@ -6384,19 +6414,25 @@ def setup_email_routes():
|
||||
{"role": "user", "content": user_msg},
|
||||
]
|
||||
try:
|
||||
reply_raw = await llm_call_async_with_fallback(
|
||||
_candidates,
|
||||
messages=_messages,
|
||||
temperature=0.7,
|
||||
max_tokens=1536 if fast_reply else 6144,
|
||||
timeout=120 if fast_reply else 180,
|
||||
)
|
||||
if callable(data.get('_emit')):
|
||||
from src.email_reply_stream import stream_reply
|
||||
reply_raw, model = await stream_reply(_candidates, _messages, data['_emit'], max_tokens=1536)
|
||||
else:
|
||||
reply_raw = await llm_call_async_with_fallback(
|
||||
_candidates,
|
||||
messages=_messages,
|
||||
temperature=0.7,
|
||||
max_tokens=1536 if fast_reply else 6144,
|
||||
timeout=120 if fast_reply else 180,
|
||||
thinking_mode='off',
|
||||
)
|
||||
except Exception as e:
|
||||
detail = getattr(e, "detail", None) or str(e)
|
||||
_attempted = ", ".join(f"{m}@{u.split('/')[2] if '/' in u else u}" for u, m, _ in _candidates) or "no candidates"
|
||||
return {"success": False, "error": f"All endpoints failed ({_attempted}): {detail}. Check your API keys in Settings → Services."}
|
||||
return {"success": False, "error": f"AI reply failed ({_attempted}): {detail}"}
|
||||
|
||||
reply = _apply_email_style_mechanics(_extract_reply(reply_raw or ""))
|
||||
from src.email_reply_stream import reply_body
|
||||
reply = _apply_email_style_mechanics(reply_body(reply_raw or "", complete=True))
|
||||
# Small/local models sometimes satisfy the format request with a
|
||||
# one-word acknowledgement ("Thanks.") even though the email
|
||||
# needs an actual draft. Treat that as an unusable result and
|
||||
@@ -6432,8 +6468,9 @@ def setup_email_routes():
|
||||
max_tokens=2048 if fast_reply else 4096,
|
||||
timeout=90 if fast_reply else 120,
|
||||
max_retries=1,
|
||||
thinking_mode='off',
|
||||
)
|
||||
retry_reply = _apply_email_style_mechanics(_extract_reply(raw_retry or ""))
|
||||
retry_reply = _apply_email_style_mechanics(reply_body(raw_retry or "", complete=True))
|
||||
if retry_reply and (len(retry_reply.split()) >= 4 or allow_short_reply):
|
||||
reply = retry_reply
|
||||
model = cand_model
|
||||
|
||||
@@ -265,6 +265,7 @@ def _is_openai_api_base(url: str) -> bool:
|
||||
|
||||
|
||||
_GALLERY_ENDPOINT_PATHS = frozenset({
|
||||
"/images",
|
||||
"/images/edits",
|
||||
"/images/generations",
|
||||
"/images/harmonize",
|
||||
@@ -305,12 +306,28 @@ def _first_visible_image_endpoint(db, owner: str | None):
|
||||
return endpoints[0] if endpoints else None
|
||||
|
||||
|
||||
def _visible_image_endpoint_for_base(db, base: str, owner: str | None):
|
||||
def _visible_image_endpoint_for_base(db, base: str, owner: str | None, model: str = ""):
|
||||
import json
|
||||
target = _normalize_image_endpoint_base(base)
|
||||
if not target:
|
||||
return None
|
||||
fallback = None
|
||||
for ep in _visible_image_endpoint_query(db, owner).all():
|
||||
from src.auth_helpers import owner_filter
|
||||
from src.image_model_ids import looks_like_image_generation_model
|
||||
query = owner_filter(db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True), ModelEndpoint, owner)
|
||||
for ep in query.all():
|
||||
if getattr(ep, "model_type", None) != "image":
|
||||
# Mixed providers are eligible only for a configured image model.
|
||||
configured = set()
|
||||
for field in ("cached_models", "pinned_models"):
|
||||
try:
|
||||
values = getattr(ep, field, None) or []
|
||||
values = json.loads(values) if isinstance(values, str) else values
|
||||
configured.update(v for v in values if isinstance(v, str))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if model not in configured or not looks_like_image_generation_model(model):
|
||||
continue
|
||||
if _normalize_image_endpoint_base(getattr(ep, "base_url", "")) == target:
|
||||
if owner and getattr(ep, "owner", None) == owner:
|
||||
return ep
|
||||
@@ -1293,7 +1310,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
# so the outbound URL never depends directly on request-body input.
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ep = _visible_image_endpoint_for_base(db, requested_base, user)
|
||||
ep = _visible_image_endpoint_for_base(db, requested_base, user, chosen_model)
|
||||
if not ep:
|
||||
raise HTTPException(403, "Choose a registered image endpoint")
|
||||
base = ep.base_url.rstrip("/")
|
||||
@@ -1305,8 +1322,10 @@ def setup_gallery_routes() -> APIRouter:
|
||||
base += "/v1"
|
||||
|
||||
is_openai = _is_openai_api_base(base)
|
||||
from src.model_capability_readers.base import detect_vendor
|
||||
is_openrouter = detect_vendor(base) == "openrouter"
|
||||
|
||||
if is_openai:
|
||||
if is_openai or is_openrouter:
|
||||
# OpenAI path: /v1/images/edits with gpt-image-1.
|
||||
# Mask convention differs from Stable Diffusion:
|
||||
# SD: white pixels = regenerate, black = keep
|
||||
@@ -1374,7 +1393,23 @@ def setup_gallery_routes() -> APIRouter:
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
r = await client.post(_join_checked_gallery_endpoint(base, "/images/edits"), headers=headers, data=data, files=files)
|
||||
if is_openrouter:
|
||||
# Reference-based editing, then composite locally so
|
||||
# pixels outside the user's mask remain unchanged.
|
||||
reference_mask = io.BytesIO()
|
||||
mask_png.save(reference_mask, format="PNG")
|
||||
references = [src_buf.getvalue(), reference_mask.getvalue()]
|
||||
payload = {
|
||||
"model": oa_model, "size": size, "n": 1,
|
||||
"output_format": "png",
|
||||
"prompt": "Edit the first image. The second image is a mask: white marks the region to edit, black marks the region to preserve. Keep composition and framing unchanged. Requested edit: " + str(body.get("prompt", "")),
|
||||
"input_references": [{"type": "image_url", "image_url": {
|
||||
"url": "data:image/png;base64," + base64.b64encode(value).decode(),
|
||||
}} for value in references],
|
||||
}
|
||||
r = await client.post(_join_checked_gallery_endpoint(base, "/images"), headers=headers, json=payload)
|
||||
else:
|
||||
r = await client.post(_join_checked_gallery_endpoint(base, "/images/edits"), headers=headers, data=data, files=files)
|
||||
if r.status_code != 200:
|
||||
logger.error("inpaint_proxy OpenAI edit: status %s", r.status_code)
|
||||
raise HTTPException(r.status_code, "OpenAI edit failed")
|
||||
@@ -1410,10 +1445,9 @@ def setup_gallery_routes() -> APIRouter:
|
||||
blended.save(out_buf, format="PNG")
|
||||
return {"image": base64.b64encode(out_buf.getvalue()).decode()}
|
||||
except Exception as comp_err:
|
||||
# If compositing fails for any reason, fall back
|
||||
# to the raw OpenAI output rather than blocking.
|
||||
logger.warning(f"Inpaint compose failed, returning raw: {comp_err}")
|
||||
return {"image": raw_b64}
|
||||
# Never return a full-image edit when masking fails.
|
||||
logger.warning(f"Inpaint compose failed: {comp_err}")
|
||||
raise HTTPException(502, "Could not apply the edit within the selected region")
|
||||
except httpx.TimeoutException:
|
||||
raise HTTPException(504, "OpenAI inpaint timed out (120s)")
|
||||
|
||||
@@ -2016,12 +2050,14 @@ def setup_gallery_routes() -> APIRouter:
|
||||
|
||||
try:
|
||||
from rembg import remove
|
||||
cut = remove(crop)
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
cut = await run_in_threadpool(remove, crop)
|
||||
except ImportError:
|
||||
try:
|
||||
from transformers import pipeline
|
||||
pipe = pipeline("image-segmentation", model="briaai/RMBG-1.4", trust_remote_code=True)
|
||||
mask_img = pipe(crop, return_mask=True).convert("L")
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
pipe = await run_in_threadpool(pipeline, "image-segmentation", model="briaai/RMBG-1.4", trust_remote_code=True)
|
||||
mask_img = (await run_in_threadpool(pipe, crop, return_mask=True)).convert("L")
|
||||
tmp = crop.copy()
|
||||
tmp.putalpha(mask_img)
|
||||
cut = tmp
|
||||
|
||||
+17
-4
@@ -1507,7 +1507,7 @@ def setup_model_routes(model_discovery):
|
||||
# opens from starting duplicate /models probes, and gives slow/offline
|
||||
# providers a cooldown after failures.
|
||||
_refresh_state: Dict[str, Dict[str, Any]] = {}
|
||||
_refresh_inflight = {"v": False} # coarse single-flight guard
|
||||
_refresh_inflight = {"v": False, "done": None} # coarse single-flight guard
|
||||
_REFRESH_FAILURE_BASE = 300.0
|
||||
_REFRESH_FAILURE_MAX = 3600.0
|
||||
|
||||
@@ -1581,7 +1581,9 @@ def setup_model_routes(model_discovery):
|
||||
endpoints are skipped unless explicitly forced."""
|
||||
import threading
|
||||
if _refresh_inflight["v"]:
|
||||
return # already running
|
||||
return _refresh_inflight["done"] # already running
|
||||
done = threading.Event()
|
||||
_refresh_inflight["done"] = done
|
||||
_refresh_inflight["v"] = True
|
||||
|
||||
def _do():
|
||||
@@ -1649,7 +1651,9 @@ def setup_model_routes(model_discovery):
|
||||
for st in _refresh_state.values():
|
||||
st["inflight"] = False
|
||||
_refresh_inflight["v"] = False
|
||||
done.set()
|
||||
threading.Thread(target=_do, daemon=True).start()
|
||||
return done
|
||||
|
||||
def _fetch_models(owner: str = "", is_admin: bool = False):
|
||||
"""Return model list from cached data (instant). Background refresh keeps caches fresh.
|
||||
@@ -1740,7 +1744,8 @@ def setup_model_routes(model_discovery):
|
||||
return {"hosts": [], "items": items}
|
||||
|
||||
@router.get("/models")
|
||||
def api_models(request: Request, refresh: bool = False, background: bool = False):
|
||||
def api_models(request: Request, refresh: bool = False, background: bool = False,
|
||||
wait_refresh: bool = False):
|
||||
"""Get available models — per-user (caller sees only their endpoints +
|
||||
legacy/shared null-owner rows). Cached per-user for 30s."""
|
||||
# Require auth; "" is the unconfigured single-user mode, treated as
|
||||
@@ -1786,7 +1791,15 @@ def setup_model_routes(model_discovery):
|
||||
# Page boot can opt out with background=false so opening Odysseus does
|
||||
# not start endpoint probes against slow/offline model servers.
|
||||
if background or refresh:
|
||||
_refresh_caches_bg(force=refresh)
|
||||
done = _refresh_caches_bg(force=refresh)
|
||||
if refresh and wait_refresh and done is not None:
|
||||
done.wait(timeout=15)
|
||||
refreshed = _models_cache.get(_cache_key)
|
||||
if refreshed is not None:
|
||||
result = refreshed["data"]
|
||||
else:
|
||||
result = _fetch_models(owner=owner, is_admin=_is_admin)
|
||||
_models_cache[_cache_key] = {"data": result, "time": _time.time()}
|
||||
return result
|
||||
|
||||
# Brief cache for local-probe results so picker-open doesn't hammer
|
||||
|
||||
@@ -797,8 +797,19 @@ def setup_session_routes(
|
||||
pass
|
||||
return {"deleted": deleted_count}
|
||||
|
||||
@router.get("/session/{sid}/deletion-info")
|
||||
def session_deletion_info(request: Request, sid: str):
|
||||
_verify_session_owner(request, sid, session_manager)
|
||||
from src.session_image_cleanup import session_gallery_images
|
||||
db = SessionLocal()
|
||||
try:
|
||||
count = session_gallery_images(db, sid).filter(GalleryImage.is_active.is_(True)).count()
|
||||
return {"image_count": count}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@router.delete("/session/{sid}")
|
||||
def delete_session(request: Request, sid: str):
|
||||
def delete_session(request: Request, sid: str, delete_images: bool = False):
|
||||
"""Permanently delete a session and all its messages."""
|
||||
_verify_session_owner(request, sid, session_manager)
|
||||
try:
|
||||
@@ -815,7 +826,8 @@ def setup_session_routes(
|
||||
db.close()
|
||||
|
||||
# Delete the session and all its messages
|
||||
if session_manager.delete_session(sid):
|
||||
if (session_manager.delete_session(sid, delete_images=True) if delete_images
|
||||
else session_manager.delete_session(sid)):
|
||||
from routes.chat_helpers import remove_session_sft_trace_rows
|
||||
remove_session_sft_trace_rows(effective_user(request), sid)
|
||||
return {"status": "deleted"}
|
||||
@@ -834,7 +846,7 @@ def setup_session_routes(
|
||||
)
|
||||
|
||||
@router.delete("/sessions/all")
|
||||
def delete_all_sessions(request: Request):
|
||||
def delete_all_sessions(request: Request, delete_images: bool = False):
|
||||
"""Admin only: permanently delete ALL sessions and their messages."""
|
||||
from core.middleware import require_admin
|
||||
require_admin(request)
|
||||
@@ -861,7 +873,7 @@ def setup_session_routes(
|
||||
if filenames:
|
||||
clauses.append(GalleryImage.filename.in_(list(filenames)))
|
||||
image_query = db.query(GalleryImage).filter(or_(*clauses))
|
||||
images = image_query.all()
|
||||
images = image_query.all() if delete_images else []
|
||||
removed_images = 0
|
||||
for img in images:
|
||||
img.is_active = False
|
||||
@@ -873,6 +885,9 @@ def setup_session_routes(
|
||||
except Exception as exc:
|
||||
logger.warning("Could not remove generated image %s during all-session delete: %s", img.filename, exc)
|
||||
removed_images += 1
|
||||
db.query(GalleryImage).filter(GalleryImage.session_id.in_(session_ids)).update(
|
||||
{GalleryImage.session_id: None}, synchronize_session=False
|
||||
)
|
||||
db.query(DbChatMessage).delete()
|
||||
db.query(DbSession).delete()
|
||||
db.commit()
|
||||
|
||||
Reference in New Issue
Block a user