mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 07:35:24 +00:00
feat: finish the ai plugin (B18)
This commit is contained in:
1 parent
6b4d1432ab
commit
21937a6b02
193 files changed
+23429
-4244
No files matched your search
@@ -0,0 +1,216 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { startServer, type TestServer } from "./helpers";
|
||||
|
||||
let server: TestServer | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
server = null;
|
||||
});
|
||||
|
||||
describe("ai routes", () => {
|
||||
it("reports status without the gate, and blocks everything else while off", async () => {
|
||||
server = await startServer();
|
||||
|
||||
const status = await server.request("GET", "/status");
|
||||
expect(status.status).toBe(200);
|
||||
expect(status.body).toEqual({
|
||||
globallyEnabled: false,
|
||||
enabled: false,
|
||||
allowReadOnlyCommands: false,
|
||||
});
|
||||
|
||||
expect((await server.request("GET", "/providers")).status).toBe(403);
|
||||
});
|
||||
|
||||
it("lets a user opt in only while the server allows it", async () => {
|
||||
server = await startServer();
|
||||
|
||||
const refused = await server.request("PUT", "/opt-in", {
|
||||
body: { enabled: true },
|
||||
});
|
||||
expect(refused.status).toBe(403);
|
||||
|
||||
await server.mock.ctx.settings.set("globallyEnabled", true);
|
||||
const accepted = await server.request("PUT", "/opt-in", {
|
||||
body: { enabled: true },
|
||||
});
|
||||
expect(accepted.status).toBe(200);
|
||||
expect(await server.mock.ctx.settings.getUser("user-1", "enabled")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a provider's key in ctx.secrets, never in the row or the response", async () => {
|
||||
server = await startServer();
|
||||
await server.enableFor("user-1");
|
||||
|
||||
const created = await server.request("POST", "/providers", {
|
||||
body: {
|
||||
providerType: "openai",
|
||||
label: "Work",
|
||||
apiKey: "sk-secret-value",
|
||||
defaultModel: "gpt-5",
|
||||
},
|
||||
});
|
||||
expect(created.status).toBe(201);
|
||||
expect(created.body.provider.apiKeyPrefix).toBe("sk-sec");
|
||||
expect(JSON.stringify(created.body)).not.toContain("sk-secret-value");
|
||||
|
||||
const id = created.body.provider.id;
|
||||
expect(server.mock.secretStore.get(`user-1:provider:${id}`)).toBe(
|
||||
"sk-secret-value",
|
||||
);
|
||||
const row = server.db.sqlite
|
||||
.prepare("SELECT * FROM p_ai_providers WHERE id = ?")
|
||||
.get(id) as Record<string, unknown>;
|
||||
expect(JSON.stringify(row)).not.toContain("sk-secret-value");
|
||||
|
||||
const list = await server.request("GET", "/providers");
|
||||
expect(list.body.providers).toHaveLength(1);
|
||||
|
||||
// Another user sees none of it.
|
||||
await server.enableFor("user-2");
|
||||
const other = await server.request("GET", "/providers", { user: "user-2" });
|
||||
expect(other.body.providers).toEqual([]);
|
||||
|
||||
const removed = await server.request("DELETE", `/providers/${id}`);
|
||||
expect(removed.status).toBe(200);
|
||||
expect(server.mock.secretStore.has(`user-1:provider:${id}`)).toBe(false);
|
||||
});
|
||||
|
||||
it("validates a new provider", async () => {
|
||||
server = await startServer();
|
||||
await server.enableFor("user-1");
|
||||
|
||||
const unknown = await server.request("POST", "/providers", {
|
||||
body: { providerType: "nope", label: "x" },
|
||||
});
|
||||
expect(unknown.status).toBe(400);
|
||||
|
||||
const noKey = await server.request("POST", "/providers", {
|
||||
body: { providerType: "anthropic", label: "x" },
|
||||
});
|
||||
expect(noKey.status).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects and applies proposals only once, for their owner", async () => {
|
||||
server = await startServer();
|
||||
await server.enableFor("user-1");
|
||||
|
||||
server.db.sqlite
|
||||
.prepare(
|
||||
"INSERT INTO p_ai_conversations (id, user_id, title) VALUES (1, 'user-1', 't')",
|
||||
)
|
||||
.run();
|
||||
server.db.sqlite
|
||||
.prepare(
|
||||
`INSERT INTO p_ai_proposals (id, conversation_id, user_id, kind, payload)
|
||||
VALUES (1, 1, 'user-1', 'propose_create_host', '{"name":"db","ip":"10.0.0.2"}'),
|
||||
(2, 1, 'user-1', 'propose_delete_host', '{"hostId":1}')`,
|
||||
)
|
||||
.run();
|
||||
|
||||
// Not theirs.
|
||||
await server.enableFor("user-2");
|
||||
expect(
|
||||
(await server.request("POST", "/proposals/1/reject", { user: "user-2" }))
|
||||
.status,
|
||||
).toBe(404);
|
||||
|
||||
const rejected = await server.request("POST", "/proposals/2/reject");
|
||||
expect(rejected.status).toBe(200);
|
||||
expect((await server.request("POST", "/proposals/2/reject")).status).toBe(
|
||||
404,
|
||||
);
|
||||
|
||||
const conversation = await server.request("GET", "/conversations/1");
|
||||
expect(conversation.status).toBe(200);
|
||||
expect(
|
||||
conversation.body.proposals.map((p: { id: number }) => p.id),
|
||||
).toEqual([2, 1]);
|
||||
|
||||
const deleted = await server.request("DELETE", "/conversations/1");
|
||||
expect(deleted.status).toBe(200);
|
||||
const left = server.db.sqlite
|
||||
.prepare("SELECT COUNT(*) AS n FROM p_ai_proposals")
|
||||
.get() as { n: number };
|
||||
expect(left.n).toBe(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["GET", "/providers", "ai.use"],
|
||||
["POST", "/providers", "ai.manage_providers"],
|
||||
["PATCH", "/providers/1", "ai.manage_providers"],
|
||||
["DELETE", "/providers/1", "ai.manage_providers"],
|
||||
["POST", "/probe-models", "ai.manage_providers"],
|
||||
["GET", "/providers/1/models", "ai.use"],
|
||||
["GET", "/conversations", "ai.use"],
|
||||
["GET", "/conversations/1", "ai.use"],
|
||||
["DELETE", "/conversations/1", "ai.use"],
|
||||
["POST", "/chat/stream", "ai.use"],
|
||||
["POST", "/proposals/1/apply", "ai.apply_proposals"],
|
||||
["POST", "/proposals/1/mark-run-in-terminal", "ai.apply_proposals"],
|
||||
["POST", "/proposals/1/reject", "ai.use"],
|
||||
["PUT", "/opt-in", "ai.use"],
|
||||
])("%s %s answers 403 without %s", async (method, path, permission) => {
|
||||
server = await startServer({
|
||||
permissions: [
|
||||
"ai.use",
|
||||
"ai.manage_providers",
|
||||
"ai.apply_proposals",
|
||||
].filter((entry) => entry !== permission),
|
||||
});
|
||||
await server.enableFor("user-1");
|
||||
const response = await server.request(method, path, { body: {} });
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chat stream", () => {
|
||||
it("offers the model only the tools whose plugins are running", async () => {
|
||||
const bodies: Array<{ tools?: Array<{ function?: { name: string } }> }> =
|
||||
[];
|
||||
const encoder = new TextEncoder();
|
||||
server = await startServer({
|
||||
services: { "snippets.access": { list: async () => [] } },
|
||||
fetch: async (_url, init) => {
|
||||
bodies.push(JSON.parse((init as { body: string }).body));
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
'data: {"choices":[{"delta":{"content":"hi"}}]}\n\ndata: [DONE]\n\n',
|
||||
),
|
||||
);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
return new Response(stream, { status: 200 });
|
||||
},
|
||||
});
|
||||
await server.enableFor("user-1");
|
||||
|
||||
const created = await server.request("POST", "/providers", {
|
||||
body: {
|
||||
providerType: "openai",
|
||||
label: "Work",
|
||||
apiKey: "sk-test",
|
||||
defaultModel: "gpt-5",
|
||||
},
|
||||
});
|
||||
|
||||
const reply = await server.request("POST", "/chat/stream", {
|
||||
body: { providerId: created.body.provider.id, message: "hello" },
|
||||
});
|
||||
expect(reply.status).toBe(200);
|
||||
expect(String(reply.body)).toContain('"type":"done"');
|
||||
|
||||
const offered = (bodies[0].tools ?? []).map((tool) => tool.function?.name);
|
||||
expect(offered).toContain("list_hosts");
|
||||
expect(offered).toContain("list_snippets");
|
||||
expect(offered).not.toContain("list_fleets");
|
||||
expect(offered).not.toContain("list_homepage_items");
|
||||
expect(offered).not.toContain("propose_create_automation");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user