From 48b2a8523ac6a3e2e35e5d191c3bc9dc59308a8e Mon Sep 17 00:00:00 2001 From: root Date: Tue, 7 Jul 2026 08:58:36 -0500 Subject: [PATCH] reactor.py: fix llm_call() silently skipping fallback for explicit provider requests, add /comms/sent/{id}/send endpoint for approving queued drafts --- deploy/reactor.py | 59 +++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 49 insertions(+), 10 deletions(-) diff --git a/deploy/reactor.py b/deploy/reactor.py index 6939e19..47b3502 100644 --- a/deploy/reactor.py +++ b/deploy/reactor.py @@ -130,18 +130,29 @@ async def handle_shell(payload: dict) -> dict: # ═══════════════════════════════════════════════════════════════════════════════ async def llm_call(messages: list, provider: str = "claude", system: str = "") -> str: - if provider == "claude" and CLAUDE_API_KEY: - return await _claude_call(messages, system) - elif provider == "groq" and GROQ_API_KEY: - return await _groq_call(messages, system) - elif provider == "ollama": - return await _ollama_call(messages, system) - for p in ["groq", "ollama"]: + # Fixed 2026-07-07: previously, an explicitly-requested provider (e.g. "claude") + # called its API directly with no exception handling, so any failure (rate limit, + # depleted credits, outage) raised straight up instead of falling back to the other + # providers below. The fallback loop only ever ran for an unrecognized provider + # string, which never happens in practice — so callers requesting "claude" got zero + # real resilience. Now every explicit request tries its provider first, then falls + # through the remaining ones in order before giving up. + order = {"claude": ["claude", "groq", "ollama"], + "groq": ["groq", "ollama"], + "ollama": ["ollama"]}.get(provider, ["claude", "groq", "ollama"]) + last_err = None + for p in order: try: - return await llm_call(messages, p, system) - except Exception: + if p == "claude" and CLAUDE_API_KEY: + return await _claude_call(messages, system) + elif p == "groq" and GROQ_API_KEY: + return await _groq_call(messages, system) + elif p == "ollama": + return await _ollama_call(messages, system) + except Exception as e: + last_err = e continue - raise RuntimeError("All LLM providers failed") + raise RuntimeError(f"All LLM providers failed (last error: {last_err})") async def _claude_call(messages: list, system: str = "") -> str: payload = {"model": CLAUDE_MODEL, "max_tokens": 4096, "messages": messages} @@ -2785,5 +2796,33 @@ async def comms_sent_delete(sent_id: int): await db_execute("DELETE FROM email_sent WHERE id=%s", (sent_id,)) return {"ok": True} +@app.post("/comms/sent/{sent_id}/send") +async def comms_sent_send(sent_id: int): + """ + Added 2026-07-07: sends a previously-composed queued draft. Compose always + created a draft (status='queued') for review, but there was no action anywhere + to actually send one — this closes that gap. handle_send_email() records its own + fresh row in email_sent (sent/failed), so the original queued draft row is removed + here once we've handed its content off, rather than leaving a stale duplicate. + """ + row = await db_fetchone( + "SELECT account, to_email, to_name, subject, body, triage_id FROM email_sent WHERE id=%s AND status='queued'", + (sent_id,) + ) + if not row: + raise HTTPException(status_code=404, detail="Queued draft not found") + + result = await handle_send_email({ + "account": row["account"], + "to_email": row["to_email"], + "to_name": row["to_name"], + "subject": row["subject"], + "body": row["body"], + "triage_id": row["triage_id"], + }) + + await db_execute("DELETE FROM email_sent WHERE id=%s", (sent_id,)) + return result + if __name__ == "__main__": uvicorn.run("reactor:app", host=HOST, port=PORT, log_level="info", access_log=False)