Files
canvas-frontend/llm-proxy/server.mjs
T
shad 968273bdcb
build-and-publish / test (push) Has been cancelled
build-and-publish / image (push) Has been cancelled
feat(llm-proxy): port to Node + @earendil-works/pi-ai (#6)
2026-06-14 21:37:52 +00:00

129 lines
3.5 KiB
JavaScript

// 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}`);
});