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
@@ -1,49 +1,64 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createProviderFetch } from "../../../src/backend/providers/http.js";
|
||||
import { PRIVATE_DESTINATION_MESSAGE } from "../../../src/backend/egress.js";
|
||||
|
||||
const fetchWithProxy = vi.fn();
|
||||
const getFetchDispatcher = vi.fn();
|
||||
const safeOutboundFetch = vi.fn();
|
||||
const readPrivateAllowlist = vi.fn();
|
||||
const evaluateEgress = vi.fn();
|
||||
const globalFetch = vi.fn();
|
||||
|
||||
vi.mock("../../../../../src/backend/utils/proxy-agent.js", () => ({
|
||||
fetchWithProxy,
|
||||
getFetchDispatcher,
|
||||
}));
|
||||
vi.mock("../../../../../src/backend/utils/safe-outbound-fetch.js", () => ({
|
||||
safeOutboundFetch,
|
||||
}));
|
||||
vi.mock("../../../src/backend/egress.js", () => ({
|
||||
readPrivateAllowlist,
|
||||
evaluateEgress,
|
||||
}));
|
||||
|
||||
const { providerFetch } =
|
||||
await import("../../../src/backend/providers/http.js");
|
||||
|
||||
describe("providerFetch", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("fetch", globalFetch);
|
||||
readPrivateAllowlist.mockResolvedValue(["192.168.1.50"]);
|
||||
});
|
||||
|
||||
it("uses the matching Undici fetch implementation for private providers", async () => {
|
||||
const response = { ok: true, status: 200 } as Response;
|
||||
const init = { method: "GET" };
|
||||
evaluateEgress.mockReturnValue({ allowed: true, isPrivate: true });
|
||||
fetchWithProxy.mockResolvedValue(response);
|
||||
describe("createProviderFetch", () => {
|
||||
it("sends provider requests through ctx.fetch with the admin allowlist", async () => {
|
||||
const response = new Response("{}");
|
||||
const fetch = vi.fn(async () => response);
|
||||
const controller = new AbortController();
|
||||
const providerFetch = createProviderFetch(fetch, ["192.168.1.50"]);
|
||||
|
||||
await expect(
|
||||
providerFetch("http://192.168.1.50:11434/api/tags", init),
|
||||
providerFetch("http://192.168.1.50:11434/api/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
signal: controller.signal,
|
||||
}),
|
||||
).resolves.toBe(response);
|
||||
|
||||
expect(fetchWithProxy).toHaveBeenCalledWith(
|
||||
"http://192.168.1.50:11434/api/tags",
|
||||
init,
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"http://192.168.1.50:11434/api/chat",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: "{}",
|
||||
signal: controller.signal,
|
||||
allowPrivateHosts: ["192.168.1.50"],
|
||||
}),
|
||||
);
|
||||
expect(globalFetch).not.toHaveBeenCalled();
|
||||
expect(safeOutboundFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a private address the admin has not allowed, before any request", async () => {
|
||||
const fetch = vi.fn();
|
||||
const providerFetch = createProviderFetch(fetch, ["localhost"]);
|
||||
|
||||
await expect(
|
||||
providerFetch("http://10.0.0.9:11434/api/tags", { method: "GET" }),
|
||||
).rejects.toThrow(PRIVATE_DESTINATION_MESSAGE);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("explains a hostname that resolved to a private address", async () => {
|
||||
const fetch = vi.fn(async () => {
|
||||
throw new Error("Private destinations are not allowed");
|
||||
});
|
||||
const providerFetch = createProviderFetch(fetch, []);
|
||||
|
||||
await expect(
|
||||
providerFetch("https://ollama.example.com/api/tags", { method: "GET" }),
|
||||
).rejects.toThrow(PRIVATE_DESTINATION_MESSAGE);
|
||||
});
|
||||
|
||||
it("passes other failures through unchanged", async () => {
|
||||
const fetch = vi.fn(async () => {
|
||||
throw new Error("socket hang up");
|
||||
});
|
||||
const providerFetch = createProviderFetch(fetch, []);
|
||||
|
||||
await expect(
|
||||
providerFetch("https://api.openai.com/v1/models", { method: "GET" }),
|
||||
).rejects.toThrow("socket hang up");
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,9 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChatChunk } from "../../../src/backend/providers/types.js";
|
||||
|
||||
// Adapters get the egress-checked fetch through their config.
|
||||
const providerFetch = vi.fn();
|
||||
|
||||
vi.mock("../../../src/backend/providers/http.js", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("../../../src/backend/providers/http.js")
|
||||
>("../../../src/backend/providers/http.js");
|
||||
return { ...actual, providerFetch };
|
||||
});
|
||||
|
||||
const { openAiAdapter } =
|
||||
await import("../../../src/backend/providers/openai.js");
|
||||
const { ollamaAdapter } =
|
||||
@@ -69,7 +63,10 @@ describe("openAiAdapter", () => {
|
||||
);
|
||||
|
||||
const chunks = await collect(
|
||||
openAiAdapter.streamChat({ providerType: "openai" }, REQUEST),
|
||||
openAiAdapter.streamChat(
|
||||
{ fetch: providerFetch, providerType: "openai" },
|
||||
REQUEST,
|
||||
),
|
||||
);
|
||||
|
||||
expect(chunks.filter((c) => c.type === "text")).toEqual([
|
||||
@@ -89,7 +86,10 @@ describe("openAiAdapter", () => {
|
||||
);
|
||||
|
||||
const chunks = await collect(
|
||||
openAiAdapter.streamChat({ providerType: "openai" }, REQUEST),
|
||||
openAiAdapter.streamChat(
|
||||
{ fetch: providerFetch, providerType: "openai" },
|
||||
REQUEST,
|
||||
),
|
||||
);
|
||||
|
||||
const call = chunks.find((c) => c.type === "tool_call");
|
||||
@@ -110,7 +110,10 @@ describe("openAiAdapter", () => {
|
||||
);
|
||||
|
||||
const chunks = await collect(
|
||||
openAiAdapter.streamChat({ providerType: "openai" }, REQUEST),
|
||||
openAiAdapter.streamChat(
|
||||
{ fetch: providerFetch, providerType: "openai" },
|
||||
REQUEST,
|
||||
),
|
||||
);
|
||||
|
||||
expect(chunks.find((c) => c.type === "tool_call")).toMatchObject({
|
||||
@@ -122,7 +125,7 @@ describe("openAiAdapter", () => {
|
||||
await expect(
|
||||
collect(
|
||||
openAiAdapter.streamChat(
|
||||
{ providerType: "openai_compatible" },
|
||||
{ fetch: providerFetch, providerType: "openai_compatible" },
|
||||
REQUEST,
|
||||
),
|
||||
),
|
||||
@@ -143,7 +146,10 @@ describe("ollamaAdapter", () => {
|
||||
);
|
||||
|
||||
const chunks = await collect(
|
||||
ollamaAdapter.streamChat({ providerType: "ollama" }, REQUEST),
|
||||
ollamaAdapter.streamChat(
|
||||
{ fetch: providerFetch, providerType: "ollama" },
|
||||
REQUEST,
|
||||
),
|
||||
);
|
||||
|
||||
expect(chunks.filter((c) => c.type === "text")).toHaveLength(2);
|
||||
@@ -160,7 +166,10 @@ describe("ollamaAdapter", () => {
|
||||
);
|
||||
|
||||
const chunks = await collect(
|
||||
ollamaAdapter.streamChat({ providerType: "ollama" }, REQUEST),
|
||||
ollamaAdapter.streamChat(
|
||||
{ fetch: providerFetch, providerType: "ollama" },
|
||||
REQUEST,
|
||||
),
|
||||
);
|
||||
|
||||
const calls = chunks.filter((c) => c.type === "tool_call") as any[];
|
||||
@@ -196,7 +205,7 @@ describe("geminiAdapter", () => {
|
||||
|
||||
const chunks = await collect(
|
||||
geminiAdapter.streamChat(
|
||||
{ providerType: "gemini", apiKey: "k" },
|
||||
{ fetch: providerFetch, providerType: "gemini", apiKey: "k" },
|
||||
REQUEST,
|
||||
),
|
||||
);
|
||||
@@ -211,7 +220,7 @@ describe("geminiAdapter", () => {
|
||||
|
||||
await collect(
|
||||
geminiAdapter.streamChat(
|
||||
{ providerType: "gemini", apiKey: "k" },
|
||||
{ fetch: providerFetch, providerType: "gemini", apiKey: "k" },
|
||||
{
|
||||
...REQUEST,
|
||||
messages: [
|
||||
@@ -271,7 +280,12 @@ describe("assertOk error messages", () => {
|
||||
);
|
||||
|
||||
await expect(
|
||||
collect(openAiAdapter.streamChat({ providerType: "openai" }, REQUEST)),
|
||||
collect(
|
||||
openAiAdapter.streamChat(
|
||||
{ fetch: providerFetch, providerType: "openai" },
|
||||
REQUEST,
|
||||
),
|
||||
),
|
||||
).rejects.toThrow("Function call is missing a signature.");
|
||||
});
|
||||
|
||||
@@ -284,7 +298,12 @@ describe("assertOk error messages", () => {
|
||||
);
|
||||
|
||||
await expect(
|
||||
collect(openAiAdapter.streamChat({ providerType: "openai" }, REQUEST)),
|
||||
collect(
|
||||
openAiAdapter.streamChat(
|
||||
{ fetch: providerFetch, providerType: "openai" },
|
||||
REQUEST,
|
||||
),
|
||||
),
|
||||
).rejects.toThrow(/rate limit reached/i);
|
||||
});
|
||||
|
||||
@@ -294,7 +313,12 @@ describe("assertOk error messages", () => {
|
||||
);
|
||||
|
||||
await expect(
|
||||
collect(openAiAdapter.streamChat({ providerType: "openai" }, REQUEST)),
|
||||
collect(
|
||||
openAiAdapter.streamChat(
|
||||
{ fetch: providerFetch, providerType: "openai" },
|
||||
REQUEST,
|
||||
),
|
||||
),
|
||||
).rejects.toThrow(/rejected the API key/i);
|
||||
});
|
||||
|
||||
@@ -302,7 +326,12 @@ describe("assertOk error messages", () => {
|
||||
providerFetch.mockResolvedValue(errorResponse(500, "upstream exploded"));
|
||||
|
||||
await expect(
|
||||
collect(openAiAdapter.streamChat({ providerType: "openai" }, REQUEST)),
|
||||
collect(
|
||||
openAiAdapter.streamChat(
|
||||
{ fetch: providerFetch, providerType: "openai" },
|
||||
REQUEST,
|
||||
),
|
||||
),
|
||||
).rejects.toThrow("upstream exploded");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user