// Node sidecar for canvas — replaces the Python httpx proxy with the same // pi-ai unified provider client the Pi coding agent uses. Frontend contract // is unchanged: POST /internal/canvas-llm/chat with {messages, max_tokens} // returns {content, provider}. Returns 503 when no provider key is set. import http from "node:http"; import { completeSimple, registerBuiltInApiProviders, } from "@earendil-works/pi-ai"; registerBuiltInApiProviders(); const PORT = Number(process.env.PORT ?? 8080); function pickModel() { if (process.env.ANTHROPIC_API_KEY) { return { providerLabel: "anthropic", api: "anthropic-messages", id: process.env.ANTHROPIC_MODEL ?? "claude-3-5-haiku-latest", apiKey: process.env.ANTHROPIC_API_KEY, }; } if (process.env.OPENAI_API_KEY) { return { providerLabel: "openai", api: "openai-completions", id: process.env.OPENAI_MODEL ?? "gpt-4o-mini", apiKey: process.env.OPENAI_API_KEY, }; } if (process.env.GEMINI_API_KEY) { return { providerLabel: "google", api: "google-gemini", id: process.env.GEMINI_MODEL ?? "gemini-2.0-flash-exp", apiKey: process.env.GEMINI_API_KEY, }; } return null; } async function readJson(req) { return new Promise((resolve, reject) => { const chunks = []; req.on("data", (c) => chunks.push(c)); req.on("end", () => { try { resolve(JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}")); } catch (e) { reject(e); } }); req.on("error", reject); }); } function send(res, status, body) { res.writeHead(status, { "content-type": "application/json" }); res.end(JSON.stringify(body)); } const server = http.createServer(async (req, res) => { if (req.method === "GET" && req.url === "/health") { const m = pickModel(); return send(res, 200, { ok: true, provider: m?.providerLabel ?? null, providers_available: { anthropic: !!process.env.ANTHROPIC_API_KEY, openai: !!process.env.OPENAI_API_KEY, google: !!process.env.GEMINI_API_KEY, }, }); } if (req.method !== "POST" || req.url !== "/internal/canvas-llm/chat") { return send(res, 404, { error: "not found" }); } let body; try { body = await readJson(req); } catch (e) { return send(res, 400, { error: `invalid json: ${e.message}` }); } const messages = Array.isArray(body.messages) ? body.messages : null; if (!messages || messages.length === 0) { return send(res, 400, { error: "messages[] required" }); } const maxTokens = Number.isFinite(body.max_tokens) ? Number(body.max_tokens) : 320; const model = pickModel(); if (!model) { return send(res, 503, { error: "natural language replies are not enabled in this environment", }); } try { const piModel = { api: model.api, id: model.id, apiKey: model.apiKey }; const context = { messages, maxTokens }; const reply = await completeSimple(piModel, context, {}); const content = typeof reply === "string" ? reply : typeof reply?.content === "string" ? reply.content : typeof reply?.text === "string" ? reply.text : ""; return send(res, 200, { content: String(content).trim(), provider: model.providerLabel, }); } catch (e) { return send(res, 502, { error: `${model.providerLabel} upstream`, message: String(e?.message ?? e).slice(0, 500), }); } }); server.listen(PORT, () => { console.log(`canvas-llm-proxy listening on :${PORT}`); });