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:
@@ -0,0 +1,7 @@
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY main.py .
|
||||
EXPOSE 8080
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||
Binary file not shown.
@@ -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"},
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
fastapi==0.115.4
|
||||
uvicorn[standard]==0.32.0
|
||||
httpx==0.27.2
|
||||
+11
@@ -32,6 +32,17 @@ server {
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
# In-cluster LLM proxy. Returns 503 when no provider key is configured,
|
||||
# so the frontend's deterministic fallback fires gracefully.
|
||||
location /internal/canvas-llm/ {
|
||||
proxy_pass http://canvas-llm-proxy.demo.svc.cluster.local:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_http_version 1.1;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
# SPA fallback to index.html for client-side routing.
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { chromium } from "playwright";
|
||||
const args = ["--host-resolver-rules=MAP canvas.flow-master.ai 65.21.71.186", "--ignore-certificate-errors"];
|
||||
const b = await chromium.launch({ headless: true, args });
|
||||
const p = await (await b.newContext({ viewport: { width: 1440, height: 900 } })).newPage();
|
||||
await p.goto("https://canvas.flow-master.ai/", { waitUntil: "networkidle" });
|
||||
await p.waitForTimeout(800);
|
||||
await p.locator(".persona-chip", { hasText: /Mariana/ }).click();
|
||||
await p.locator(".dev-login-btn").click();
|
||||
await p.waitForSelector(".hero-actions", { timeout: 15000 });
|
||||
await p.locator(".hub-chip", { hasText: /^Documents$/ }).click();
|
||||
await p.waitForSelector(".docs-row", { timeout: 20000 }).catch(() => {});
|
||||
await p.waitForTimeout(2000);
|
||||
const rows = await p.locator(".docs-row").count();
|
||||
const headers = await p.locator(".docs-row-title").allTextContents();
|
||||
console.log(`rows: ${rows}`);
|
||||
console.log(`first 5: ${headers.slice(0, 5).join(" | ")}`);
|
||||
if (rows > 0) {
|
||||
await p.locator(".docs-row").first().click();
|
||||
await p.waitForTimeout(800);
|
||||
const detailTitle = await p.locator(".docs-card-title").textContent();
|
||||
console.log(`detail title: ${detailTitle}`);
|
||||
}
|
||||
await b.close();
|
||||
Reference in New Issue
Block a user