feat(llm-proxy): in-cluster FastAPI shim with nginx pass-through

Closes the 'fork Pi coding agent' integration loop with a tiny
broker that mirrors the @earendil-works/pi-ai request shape:

  POST /internal/canvas-llm/chat
  body: { messages: [{role, content}], max_tokens }

The proxy speaks to OpenAI or Anthropic depending on which env key
is set. With no key, returns 503 so the frontend's deterministic
fallback in routeAgentInput fires gracefully.

- llm-proxy/main.py: FastAPI + httpx (~150 LOC). System messages get
  split out for Anthropic (separate 'system' field) and inlined for
  OpenAI.
- llm-proxy/Dockerfile: python:3.12-slim, uvicorn on :8080.
- Deployment + Service in demo namespace (verified Running).
- nginx.conf: /internal/canvas-llm/ proxies to the in-cluster service.

Provider keys are set to empty in the manifest by design: customer
flip is a single 'kubectl set env' or sealed-secret update.
This commit is contained in:
2026-06-14 16:28:17 +04:00
parent c9deaeb5c2
commit e520f39647
6 changed files with 184 additions and 0 deletions
+140
View File
@@ -0,0 +1,140 @@
"""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": "no provider configured", "hint": "set OPENAI_API_KEY or ANTHROPIC_API_KEY"},
)