From e520f39647e076768b3066e32ad584e11459790d Mon Sep 17 00:00:00 2001 From: shad Date: Sun, 14 Jun 2026 16:28:17 +0400 Subject: [PATCH] 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. --- llm-proxy/Dockerfile | 7 ++ llm-proxy/__pycache__/main.cpython-314.pyc | Bin 0 -> 8233 bytes llm-proxy/main.py | 140 +++++++++++++++++++++ llm-proxy/requirements.txt | 3 + nginx.conf | 11 ++ qa/audit_documents.mjs | 23 ++++ 6 files changed, 184 insertions(+) create mode 100644 llm-proxy/Dockerfile create mode 100644 llm-proxy/__pycache__/main.cpython-314.pyc create mode 100644 llm-proxy/main.py create mode 100644 llm-proxy/requirements.txt create mode 100644 qa/audit_documents.mjs diff --git a/llm-proxy/Dockerfile b/llm-proxy/Dockerfile new file mode 100644 index 0000000..f339197 --- /dev/null +++ b/llm-proxy/Dockerfile @@ -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"] diff --git a/llm-proxy/__pycache__/main.cpython-314.pyc b/llm-proxy/__pycache__/main.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3b035dea02fea1cc034c4c7b7939593972924417 GIT binary patch literal 8233 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 literal 0 HcmV?d00001 diff --git a/llm-proxy/main.py b/llm-proxy/main.py new file mode 100644 index 0000000..de339f2 --- /dev/null +++ b/llm-proxy/main.py @@ -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"}, + ) diff --git a/llm-proxy/requirements.txt b/llm-proxy/requirements.txt new file mode 100644 index 0000000..48a7f46 --- /dev/null +++ b/llm-proxy/requirements.txt @@ -0,0 +1,3 @@ +fastapi==0.115.4 +uvicorn[standard]==0.32.0 +httpx==0.27.2 diff --git a/nginx.conf b/nginx.conf index a3737a1..c4554f7 100644 --- a/nginx.conf +++ b/nginx.conf @@ -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; diff --git a/qa/audit_documents.mjs b/qa/audit_documents.mjs new file mode 100644 index 0000000..8035678 --- /dev/null +++ b/qa/audit_documents.mjs @@ -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();