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 0000000..3b035de Binary files /dev/null and b/llm-proxy/__pycache__/main.cpython-314.pyc differ 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();