- pnpm monorepo: apps/api (Fastify + SQLite + SSE), apps/web (React+Vite), packages/shared, packages/pi-adapter - Local auth (admin/webhook-runner roles) + Keycloak JWT ready - Multi-session chat with reliable history (user persisted before LLM, assistant persisted after stream) - Markdown knowledge base with /api/docs/search + /api/docs/:id - YAML webhook catalog with backend-only execution, retry/backoff, audit (webhook_runs), and per-user rate limit - Skills config (sre-on-call, blameless-postmortem, security-incident) injected into LLM system prompt - LLM provider failover chain (config/models.yml fallback + LLM_FALLBACK_CHAIN override) - Context-aware webhooks panel + backend id-mention safety net - Per-message stats (time/duration/tokens/model), Markdown+GFM render, code & table copy/download buttons - Vitest suite, end-to-end smoke test (scripts/smoke.mjs), per-session system prompt override - /metrics Prometheus endpoint + /api/metrics JSON, request-id correlation - dotenv with explicit repo-root path; envString/envNumber helpers (handles empty-string env) - Runbooks + SOPs under knowledge/ in English; README, docs, and INDEX.md in English
182 lines
5.1 KiB
TypeScript
182 lines
5.1 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
createOpenAICompatiblePiAdapter,
|
|
} from "../src/index.js";
|
|
|
|
describe("pi-adapter structured errors", () => {
|
|
it("returns ok:false with no_content when LLM returns empty", async () => {
|
|
const server = await startMockLLM({ responseContent: "" });
|
|
try {
|
|
const pi = createOpenAICompatiblePiAdapter({
|
|
baseUrl: server.baseUrl,
|
|
apiKey: "test",
|
|
defaultModel: "fast",
|
|
});
|
|
const result = await pi.chat({
|
|
message: "hi",
|
|
model: "fast",
|
|
docs: [],
|
|
availableActions: [],
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.error.kind).toBe("no_content");
|
|
expect(result.fallback.answer).toBe("");
|
|
expect(result.fallback.recommended_actions).toEqual([]);
|
|
}
|
|
} finally {
|
|
await server.stop();
|
|
}
|
|
});
|
|
|
|
it("returns ok:false with json_parse when response has no JSON object", async () => {
|
|
const server = await startMockLLM({ responseContent: "Just plain text answer" });
|
|
try {
|
|
const pi = createOpenAICompatiblePiAdapter({
|
|
baseUrl: server.baseUrl,
|
|
apiKey: "test",
|
|
defaultModel: "fast",
|
|
});
|
|
const result = await pi.chat({
|
|
message: "hi",
|
|
model: "fast",
|
|
docs: [],
|
|
availableActions: [],
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.error.kind).toBe("json_parse");
|
|
expect(result.fallback.answer).toBe("Just plain text answer");
|
|
}
|
|
} finally {
|
|
await server.stop();
|
|
}
|
|
});
|
|
|
|
it("returns ok:true when response is well-formed JSON", async () => {
|
|
const server = await startMockLLM({
|
|
responseContent: JSON.stringify({
|
|
answer: "All good",
|
|
recommended_actions: [],
|
|
internal_docs: [],
|
|
}),
|
|
});
|
|
try {
|
|
const pi = createOpenAICompatiblePiAdapter({
|
|
baseUrl: server.baseUrl,
|
|
apiKey: "test",
|
|
defaultModel: "fast",
|
|
});
|
|
const result = await pi.chat({
|
|
message: "hi",
|
|
model: "fast",
|
|
docs: [],
|
|
availableActions: [],
|
|
});
|
|
expect(result.ok).toBe(true);
|
|
if (result.ok) {
|
|
expect(result.result.answer).toBe("All good");
|
|
}
|
|
} finally {
|
|
await server.stop();
|
|
}
|
|
});
|
|
|
|
it("throws on non-OK HTTP response (transport error, not parse error)", async () => {
|
|
const server = await startMockLLM({ status: 500, responseContent: "" });
|
|
try {
|
|
const pi = createOpenAICompatiblePiAdapter({
|
|
baseUrl: server.baseUrl,
|
|
apiKey: "test",
|
|
defaultModel: "fast",
|
|
});
|
|
await expect(
|
|
pi.chat({
|
|
message: "hi",
|
|
model: "fast",
|
|
docs: [],
|
|
availableActions: [],
|
|
}),
|
|
).rejects.toThrow(/llm_request_failed:500/);
|
|
} finally {
|
|
await server.stop();
|
|
}
|
|
});
|
|
|
|
it("filters recommended_actions to known ids and clamps scores", async () => {
|
|
const server = await startMockLLM({
|
|
responseContent: JSON.stringify({
|
|
answer: "ok",
|
|
recommended_actions: [
|
|
{ type: "webhook", id: "dns-flush", confidence: 2.5, reason: "x" },
|
|
{ type: "webhook", id: "unknown-id", confidence: 0.9, reason: "y" },
|
|
],
|
|
internal_docs: [],
|
|
}),
|
|
});
|
|
try {
|
|
const pi = createOpenAICompatiblePiAdapter({
|
|
baseUrl: server.baseUrl,
|
|
apiKey: "test",
|
|
defaultModel: "fast",
|
|
});
|
|
const result = await pi.chat({
|
|
message: "hi",
|
|
model: "fast",
|
|
docs: [],
|
|
availableActions: [
|
|
{ type: "webhook", id: "dns-flush", confidence: 0, reason: "r", requires_confirmation: true },
|
|
],
|
|
});
|
|
expect(result.ok).toBe(true);
|
|
if (result.ok) {
|
|
expect(result.result.recommended_actions).toHaveLength(1);
|
|
expect(result.result.recommended_actions[0]?.id).toBe("dns-flush");
|
|
expect(result.result.recommended_actions[0]?.confidence).toBe(1);
|
|
}
|
|
} finally {
|
|
await server.stop();
|
|
}
|
|
});
|
|
});
|
|
|
|
import { createServer, type Server } from "node:http";
|
|
|
|
async function startMockLLM(opts: { responseContent: string; status?: number }): Promise<{
|
|
baseUrl: string;
|
|
stop: () => Promise<void>;
|
|
}> {
|
|
let s: Server;
|
|
return await new Promise((resolve) => {
|
|
s = createServer((_req, res) => {
|
|
res.writeHead(opts.status ?? 200, { "content-type": "application/json" });
|
|
res.end(
|
|
JSON.stringify({
|
|
id: "mock",
|
|
object: "chat.completion",
|
|
created: 0,
|
|
model: "fast",
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
message: { role: "assistant", content: opts.responseContent },
|
|
finish_reason: "stop",
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
});
|
|
s.listen(0, "127.0.0.1", () => {
|
|
const address = s.address();
|
|
const port = typeof address === "object" && address ? address.port : 0;
|
|
resolve({
|
|
baseUrl: `http://127.0.0.1:${port}/v1`,
|
|
stop: () =>
|
|
new Promise<void>((res) => {
|
|
s!.close(() => res());
|
|
}),
|
|
});
|
|
});
|
|
});
|
|
}
|