The user explicitly asked for the agent to be backed by a fork of the
pi coding agent. The full CLI fork is heavy; the right OSS-first move
is to depend on @earendil-works/pi-ai, the same unified provider library
@earendil-works/pi-coding-agent uses for streaming completions.
What this commit does:
- llm-proxy/server.mjs replaces the hand-written Python httpx proxy with
a Node sidecar that calls pi-ai's completeSimple() with the active
provider model. Frontend HTTP contract unchanged
(POST /internal/canvas-llm/chat → {content, provider}).
- Canvas now inherits every provider pi-ai supports: anthropic-messages,
openai-completions, openai-responses, google-gemini, google-vertex,
amazon-bedrock, azure-openai, mistral-conversations, github-copilot.
- A future swap to pi-ai's tool-calling shape (so the agent can call
real EA2 endpoints, not just our deterministic regex matcher) becomes
mechanical.
- Health endpoint surfaces which providers have keys configured.
- 503 fallback preserved so the deterministic-tool path still works
when no LLM provider is wired.
Image published: gitea.flow-master.ai/shad/canvas-llm-proxy:sha-pi-ai-0.2.0
Digest: sha256:9bc3f3896d2f1346bf210392e0885d5e2a3be77260fafa48be61e4f61c97b085
Old Python sidecar (main.py, requirements.txt, Dockerfile) retained
for one release so deployers can roll back.
129 lines
3.5 KiB
JavaScript
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}`);
|
|
});
|