Oracle round-8 polish caveat: the 503 body still named OPENAI_API_KEY and ANTHROPIC_API_KEY in the 'hint' field. Buyer-visible in devtools even though no UI renders it. Replaced with the same buyer-safe copy the Agent OFF pill uses: 'natural language replies are not enabled in this environment'. Image bumped to canvas-llm-proxy:sha-002. Live verified.
141 lines
5.1 KiB
Python
141 lines
5.1 KiB
Python
"""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"},
|
|
)
|