"""Minimal LLM proxy for canvas.flow-master.ai. Brokers POST /internal/canvas-llm/chat to one of: - OPENAI_API_KEY -> https://api.openai.com/v1/chat/completions - ANTHROPIC_API_KEY -> https://api.anthropic.com/v1/messages Returns {content, provider}. Returns 503 when no key is configured so the frontend's deterministic fallback fires. Request shape (mirrors @earendil-works/pi-ai): POST /internal/canvas-llm/chat body: {"messages": [{"role": "system|user|assistant", "content": "..."}], "max_tokens": 320} No streaming, no tool calls, no images — keep it tiny. The frontend already calls deterministic tools on the EA2 backend; this proxy just synthesises fluent natural-language responses when no tool matches. """ import json import os from typing import Any import httpx from fastapi import FastAPI, HTTPException, Request from fastapi.responses import JSONResponse OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "").strip() OPENAI_MODEL = os.environ.get("OPENAI_MODEL", "gpt-4o-mini").strip() ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY", "").strip() ANTHROPIC_MODEL = os.environ.get("ANTHROPIC_MODEL", "claude-3-5-haiku-latest").strip() app = FastAPI(title="canvas-llm-proxy", version="0.1.0") @app.get("/health") async def health() -> dict[str, Any]: return { "ok": True, "providers_available": { "openai": bool(OPENAI_API_KEY), "anthropic": bool(ANTHROPIC_API_KEY), }, } def _split_system_messages(messages: list[dict]) -> tuple[str, list[dict]]: """Anthropic uses a separate `system` field; OpenAI accepts inline.""" system_chunks: list[str] = [] non_system: list[dict] = [] for m in messages: if m.get("role") == "system": system_chunks.append(str(m.get("content", ""))) else: non_system.append(m) return ("\n\n".join(system_chunks), non_system) @app.post("/internal/canvas-llm/chat") async def chat(request: Request) -> JSONResponse: try: body = await request.json() except Exception as e: raise HTTPException(status_code=400, detail=f"invalid json: {e}") messages = body.get("messages", []) if not isinstance(messages, list) or not messages: raise HTTPException(status_code=400, detail="messages[] required") max_tokens = int(body.get("max_tokens", 320)) if ANTHROPIC_API_KEY: system, rest = _split_system_messages(messages) anth_messages = [ {"role": m.get("role", "user"), "content": [{"type": "text", "text": str(m.get("content", ""))}]} for m in rest if m.get("role") in ("user", "assistant") ] if not anth_messages: anth_messages = [{"role": "user", "content": [{"type": "text", "text": "Hello."}]}] payload = { "model": ANTHROPIC_MODEL, "max_tokens": max_tokens, "messages": anth_messages, } if system: payload["system"] = system async with httpx.AsyncClient(timeout=30.0) as client: r = await client.post( "https://api.anthropic.com/v1/messages", headers={ "x-api-key": ANTHROPIC_API_KEY, "anthropic-version": "2023-06-01", "content-type": "application/json", }, json=payload, ) if r.status_code >= 400: return JSONResponse( status_code=502, content={"error": "anthropic upstream", "status": r.status_code, "body": r.text[:500]}, ) data = r.json() chunks = data.get("content") or [] content = "".join(c.get("text", "") for c in chunks if c.get("type") == "text").strip() return JSONResponse(content={"content": content, "provider": "anthropic"}) if OPENAI_API_KEY: payload = { "model": OPENAI_MODEL, "max_tokens": max_tokens, "messages": [ {"role": m.get("role", "user"), "content": str(m.get("content", ""))} for m in messages ], } async with httpx.AsyncClient(timeout=30.0) as client: r = await client.post( "https://api.openai.com/v1/chat/completions", headers={ "Authorization": f"Bearer {OPENAI_API_KEY}", "content-type": "application/json", }, json=payload, ) if r.status_code >= 400: return JSONResponse( status_code=502, content={"error": "openai upstream", "status": r.status_code, "body": r.text[:500]}, ) data = r.json() choices = data.get("choices") or [] content = "" if choices: msg = choices[0].get("message") or {} content = str(msg.get("content", "")).strip() return JSONResponse(content={"content": content, "provider": "openai"}) return JSONResponse( status_code=503, content={"error": "natural language replies are not enabled in this environment"}, )