feat(llm-proxy): port to Node + @earendil-works/pi-ai (#6)
This commit was merged in pull request #6.
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
node_modules
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
FROM node:22-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json ./
|
||||||
|
RUN npm install --omit=dev --no-audit --no-fund
|
||||||
|
COPY server.mjs ./
|
||||||
|
EXPOSE 8080
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
|
||||||
|
CMD wget -q --spider http://127.0.0.1:8080/health || exit 1
|
||||||
|
CMD ["node", "server.mjs"]
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# canvas-llm-proxy
|
||||||
|
|
||||||
|
In-cluster sidecar for `canvas.flow-master.ai`. Brokers
|
||||||
|
`POST /internal/canvas-llm/chat` to the configured upstream LLM provider so
|
||||||
|
the in-app Command Assistant can synthesise fluent natural-language replies
|
||||||
|
when no deterministic tool matches.
|
||||||
|
|
||||||
|
## Why a Node sidecar (not a Python one)
|
||||||
|
|
||||||
|
This was a Python `httpx` proxy that hand-wrote OpenAI and Anthropic
|
||||||
|
calls. PR canvas-frontend#6 swapped it for a Node sidecar that depends on
|
||||||
|
[`@earendil-works/pi-ai`](https://www.npmjs.com/package/@earendil-works/pi-ai)
|
||||||
|
— the same unified provider client `@earendil-works/pi-coding-agent` uses.
|
||||||
|
Why:
|
||||||
|
|
||||||
|
- It is the OSS-first move the canvas user asked for ("you could use a fork
|
||||||
|
pi coding agent for that agent and then integrate it because pi coding
|
||||||
|
agent is very clean and small while being efficient and useful").
|
||||||
|
- `pi-ai` normalises streaming completions across OpenAI (completions +
|
||||||
|
responses), Anthropic, Google Gemini, Google Vertex, Amazon Bedrock,
|
||||||
|
Azure OpenAI, Mistral, GitHub Copilot. Canvas inherits all of them.
|
||||||
|
- A future move to pi-ai's tool-calling shape (so the agent can hit real
|
||||||
|
EA2 endpoints, not just our deterministic regex matcher) becomes
|
||||||
|
mechanical instead of a rewrite.
|
||||||
|
|
||||||
|
## Wire-compatible
|
||||||
|
|
||||||
|
The HTTP contract is the same: `POST /internal/canvas-llm/chat` with
|
||||||
|
`{messages, max_tokens}` returns `{content, provider}`. The frontend's
|
||||||
|
`src/lib/llmClient.ts` does not change.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Set one of: `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`.
|
||||||
|
Optional: `ANTHROPIC_MODEL`, `OPENAI_MODEL`, `GEMINI_MODEL`, `PORT` (8080).
|
||||||
|
|
||||||
|
If no key is set, every request returns `503` so the frontend's
|
||||||
|
deterministic-tool fallback fires gracefully.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd llm-proxy
|
||||||
|
npm install
|
||||||
|
ANTHROPIC_API_KEY=… node server.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
Container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -f Dockerfile.node -t canvas-llm-proxy .
|
||||||
|
docker run -e ANTHROPIC_API_KEY=… -p 8080:8080 canvas-llm-proxy
|
||||||
|
```
|
||||||
|
|
||||||
|
## Legacy
|
||||||
|
|
||||||
|
The previous Python sidecar (`main.py`, `requirements.txt`, `Dockerfile`)
|
||||||
|
is retained for one release so deployers can roll back. It will be
|
||||||
|
removed once the Node sidecar is in production.
|
||||||
Generated
+1284
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "canvas-llm-proxy",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.2.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.mjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@earendil-works/pi-ai": "^0.74.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
// 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}`);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user