feat(FN-2965): add custom providers API routes and settings UI

Merged v0.9.4 bringing custom AI providers to the dashboard — SettingsModal now surfaces provider configuration with an onboarding disclosure flow, backed by new API routes (`/custom-providers`) and comprehensive test coverage. The engine adds self-healing with a ghost-review fallback recovery path,

Fusion-Task-Id: FN-2965
This commit is contained in:
Fusion
2026-04-30 05:12:27 -07:00
committed by gsxdsm
parent 60c4b27bd1
commit 3218c05776
4 changed files with 575 additions and 215 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Add support for custom OpenAI-compatible and Anthropic-compatible API providers. Users can add, edit, and remove custom providers from Settings → Authentication or during model onboarding, with automatic ModelRegistry registration and live updates without restart.

View File

@@ -1,237 +1,238 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { CustomProvidersSection } from "../CustomProvidersSection";
import type { CustomProvider } from "../../api";
const mockFetchCustomProviders = vi.fn();
const mockAddCustomProvider = vi.fn();
const mockUpdateCustomProvider = vi.fn();
const mockDeleteCustomProvider = vi.fn();
vi.mock("../../api", () => ({
fetchCustomProviders: vi.fn(),
addCustomProvider: vi.fn(),
updateCustomProvider: vi.fn(),
deleteCustomProvider: vi.fn(),
fetchCustomProviders: (...args: unknown[]) => mockFetchCustomProviders(...args),
addCustomProvider: (...args: unknown[]) => mockAddCustomProvider(...args),
updateCustomProvider: (...args: unknown[]) => mockUpdateCustomProvider(...args),
deleteCustomProvider: (...args: unknown[]) => mockDeleteCustomProvider(...args),
}));
vi.mock("lucide-react", () => {
const ForwardRef = (props: React.SVGProps<SVGSVGElement>) => <svg {...props} />;
return {
ChevronRight: ForwardRef,
Plus: ForwardRef,
Pencil: ForwardRef,
Trash2: ForwardRef,
Loader2: ForwardRef,
AlertCircle: ForwardRef,
};
});
import { addCustomProvider, deleteCustomProvider, fetchCustomProviders, updateCustomProvider } from "../../api";
const mockedFetchCustomProviders = vi.mocked(fetchCustomProviders);
const mockedAddCustomProvider = vi.mocked(addCustomProvider);
const mockedUpdateCustomProvider = vi.mocked(updateCustomProvider);
const mockedDeleteCustomProvider = vi.mocked(deleteCustomProvider);
const baseProvider: CustomProvider = {
id: "proxy-1",
name: "Proxy One",
apiType: "openai-compatible",
baseUrl: "https://proxy.example.com/v1",
models: [{ id: "gpt-4", name: "gpt-4" }],
};
const secondProvider: CustomProvider = {
id: "proxy-2",
name: "Proxy Two",
apiType: "anthropic-compatible",
baseUrl: "https://anthropic-proxy.example.com/v1",
models: [{ id: "claude-3-7-sonnet", name: "claude-3-7-sonnet" }],
};
async function openDisclosure(user = userEvent.setup()) {
await user.click(screen.getByRole("button", { name: "Advanced: Custom Providers" }));
return user;
}
vi.mock("lucide-react", () => ({
AlertCircle: () => <svg data-testid="icon-alert" />,
ChevronRight: () => <svg data-testid="icon-chevron-right" />,
Loader2: ({ className }: { className?: string }) => <svg data-testid="icon-loader" className={className} />,
Pencil: () => <svg data-testid="icon-pencil" />,
Plus: () => <svg data-testid="icon-plus" />,
Trash2: () => <svg data-testid="icon-trash" />,
}));
describe("CustomProvidersSection", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedFetchCustomProviders.mockResolvedValue([] as CustomProvider[] & { providers: CustomProvider[] });
mockedAddCustomProvider.mockResolvedValue(baseProvider);
mockedUpdateCustomProvider.mockResolvedValue(baseProvider);
mockedDeleteCustomProvider.mockResolvedValue({ success: true });
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
mockFetchCustomProviders.mockResolvedValue([]);
mockAddCustomProvider.mockResolvedValue({
id: "test-id",
name: "Test Provider",
apiType: "openai-compatible",
baseUrl: "https://api.example.com",
});
mockUpdateCustomProvider.mockResolvedValue({
id: "test-id",
name: "Updated",
apiType: "openai-compatible",
baseUrl: "https://api.example.com",
});
mockDeleteCustomProvider.mockResolvedValue({ success: true });
});
it("renders collapsed disclosure by default", () => {
render(<CustomProvidersSection />);
expect(screen.getByRole("button", { name: /Advanced: Custom Providers/i })).toBeTruthy();
expect(screen.queryByText("No custom providers configured.")).toBeNull();
});
it("loads providers when disclosure is expanded", async () => {
render(<CustomProvidersSection />);
fireEvent.click(screen.getByRole("button", { name: /Advanced: Custom Providers/i }));
await waitFor(() => {
expect(mockFetchCustomProviders).toHaveBeenCalledTimes(1);
expect(screen.getByText("No custom providers configured.")).toBeTruthy();
});
});
it("fetches providers on mount when embedded", async () => {
render(<CustomProvidersSection embedded />);
await waitFor(() => {
expect(mockFetchCustomProviders).toHaveBeenCalledTimes(1);
});
});
it("adds a provider and refreshes list", async () => {
mockFetchCustomProviders
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{
id: "test-id",
name: "Test Provider",
apiType: "openai-compatible",
baseUrl: "https://api.example.com",
},
]);
render(<CustomProvidersSection embedded />);
await waitFor(() => {
expect(screen.getByRole("button", { name: /Add Custom Provider/i })).toBeTruthy();
});
fireEvent.click(screen.getByRole("button", { name: /Add Custom Provider/i }));
fireEvent.change(screen.getByLabelText("Provider name"), { target: { value: "Test Provider" } });
fireEvent.change(screen.getByLabelText("Base URL"), { target: { value: "https://api.example.com" } });
fireEvent.click(screen.getByRole("button", { name: "Save Provider" }));
await waitFor(() => {
expect(mockAddCustomProvider).toHaveBeenCalledWith({
name: "Test Provider",
apiType: "openai-compatible",
baseUrl: "https://api.example.com",
});
expect(screen.getByText("Test Provider")).toBeTruthy();
});
});
it("shows validation errors for empty name and invalid baseUrl", async () => {
render(<CustomProvidersSection embedded />);
await waitFor(() => {
expect(screen.getByRole("button", { name: /Add Custom Provider/i })).toBeTruthy();
});
fireEvent.click(screen.getByRole("button", { name: /Add Custom Provider/i }));
fireEvent.click(screen.getByRole("button", { name: "Save Provider" }));
await waitFor(() => {
expect(screen.getByText("Provider name is required.")).toBeTruthy();
});
fireEvent.change(screen.getByLabelText("Provider name"), { target: { value: "Name" } });
fireEvent.change(screen.getByLabelText("Base URL"), { target: { value: "ftp://example.com" } });
fireEvent.click(screen.getByRole("button", { name: "Save Provider" }));
await waitFor(() => {
expect(screen.getByText("Base URL must be a valid http/https URL.")).toBeTruthy();
});
});
it("edits an existing provider", async () => {
mockFetchCustomProviders
.mockResolvedValueOnce([
{
id: "test-id",
name: "Test Provider",
apiType: "openai-compatible",
baseUrl: "https://api.example.com",
},
])
.mockResolvedValueOnce([
{
id: "test-id",
name: "Updated Provider",
apiType: "openai-compatible",
baseUrl: "https://api.updated.example.com",
},
]);
render(<CustomProvidersSection embedded />);
await waitFor(() => {
expect(screen.getByLabelText("Edit Test Provider")).toBeTruthy();
});
fireEvent.click(screen.getByLabelText("Edit Test Provider"));
fireEvent.change(screen.getByLabelText("Provider name"), { target: { value: "Updated Provider" } });
fireEvent.change(screen.getByLabelText("Base URL"), { target: { value: "https://api.updated.example.com" } });
fireEvent.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
expect(mockUpdateCustomProvider).toHaveBeenCalledWith("test-id", {
name: "Updated Provider",
apiType: "openai-compatible",
baseUrl: "https://api.updated.example.com",
});
expect(screen.getByText("Updated Provider")).toBeTruthy();
});
});
it("deletes provider after confirmation", async () => {
mockFetchCustomProviders
.mockResolvedValueOnce([
{
id: "test-id",
name: "Test Provider",
apiType: "openai-compatible",
baseUrl: "https://api.example.com",
},
])
.mockResolvedValueOnce([]);
vi.spyOn(window, "confirm").mockReturnValue(true);
});
it("renders disclosure trigger", () => {
render(<CustomProvidersSection />);
expect(screen.getByRole("button", { name: "Advanced: Custom Providers" })).toBeInTheDocument();
});
it("fetches providers on first open and shows empty state", async () => {
render(<CustomProvidersSection />);
const user = await openDisclosure();
await waitFor(() => expect(mockedFetchCustomProviders).toHaveBeenCalledTimes(1));
expect(screen.getByText("No custom providers configured.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Add Custom Provider/ })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Advanced: Custom Providers" }));
await user.click(screen.getByRole("button", { name: "Advanced: Custom Providers" }));
expect(mockedFetchCustomProviders).toHaveBeenCalledTimes(1);
});
it("shows form fields when add is clicked", async () => {
render(<CustomProvidersSection />);
const user = await openDisclosure();
await user.click(screen.getByRole("button", { name: /Add Custom Provider/ }));
expect(screen.getByLabelText("Provider name")).toBeInTheDocument();
expect(screen.getByLabelText("API type")).toBeInTheDocument();
expect(screen.getByLabelText("Base URL")).toBeInTheDocument();
expect(screen.getByLabelText("API key")).toBeInTheDocument();
expect(screen.getByLabelText("Available models")).toBeInTheDocument();
});
it("validates required fields", async () => {
render(<CustomProvidersSection />);
const user = await openDisclosure();
await user.click(screen.getByRole("button", { name: /Add Custom Provider/ }));
await user.click(screen.getByRole("button", { name: "Save Provider" }));
expect(screen.getByText("Provider name is required.")).toBeInTheDocument();
expect(mockedAddCustomProvider).not.toHaveBeenCalled();
});
it("validates base url protocol", async () => {
render(<CustomProvidersSection />);
const user = await openDisclosure();
await user.click(screen.getByRole("button", { name: /Add Custom Provider/ }));
await user.type(screen.getByLabelText("Provider name"), "Proxy");
await user.type(screen.getByLabelText("Base URL"), "ftp://proxy");
await user.click(screen.getByRole("button", { name: "Save Provider" }));
expect(screen.getByText("Base URL must be a valid http/https URL.")).toBeInTheDocument();
await user.clear(screen.getByLabelText("Base URL"));
await user.type(screen.getByLabelText("Base URL"), "not-a-url");
await user.click(screen.getByRole("button", { name: "Save Provider" }));
expect(screen.getByText("Base URL must be a valid http/https URL.")).toBeInTheDocument();
});
it("adds provider with parsed models payload", async () => {
render(<CustomProvidersSection />);
const user = await openDisclosure();
await user.click(screen.getByRole("button", { name: /Add Custom Provider/ }));
await user.type(screen.getByLabelText("Provider name"), "Proxy Two");
await user.selectOptions(screen.getByLabelText("API type"), "openai-compatible");
await user.type(screen.getByLabelText("Base URL"), "https://proxy2.example.com/v1");
await user.type(screen.getByLabelText("API key"), "secret");
await user.type(screen.getByLabelText("Available models"), "gpt-4, gpt-4o-mini, ");
await user.click(screen.getByRole("button", { name: "Save Provider" }));
render(<CustomProvidersSection embedded />);
await waitFor(() => {
expect(mockedAddCustomProvider).toHaveBeenCalledWith({
name: "Proxy Two",
apiType: "openai-compatible",
baseUrl: "https://proxy2.example.com/v1",
apiKey: "secret",
models: [
{ id: "gpt-4", name: "gpt-4" },
{ id: "gpt-4o-mini", name: "gpt-4o-mini" },
],
});
expect(screen.getByLabelText("Delete Test Provider")).toBeTruthy();
});
fireEvent.click(screen.getByLabelText("Delete Test Provider"));
await waitFor(() => {
expect(window.confirm).toHaveBeenCalled();
expect(mockDeleteCustomProvider).toHaveBeenCalledWith("test-id");
expect(screen.getByText("No custom providers configured.")).toBeTruthy();
});
});
it("renders existing providers", async () => {
mockedFetchCustomProviders.mockResolvedValueOnce([baseProvider] as CustomProvider[] & { providers: CustomProvider[] });
it("shows load error when fetchCustomProviders fails", async () => {
mockFetchCustomProviders.mockRejectedValueOnce(new Error("load failed"));
render(<CustomProvidersSection />);
await openDisclosure();
expect(await screen.findByText("Proxy One")).toBeInTheDocument();
expect(screen.getByText("openai-compatible")).toBeInTheDocument();
expect(screen.getByText(/https:\/\/proxy\.example\.com\/v1/)).toBeInTheDocument();
});
it("edits provider inline and pre-populates models", async () => {
mockedFetchCustomProviders.mockResolvedValueOnce(
[baseProvider, secondProvider] as CustomProvider[] & { providers: CustomProvider[] },
);
render(<CustomProvidersSection />);
const user = await openDisclosure();
await user.click(await screen.findByRole("button", { name: "Edit Proxy One" }));
const updatedField = screen.getByLabelText("Provider name");
expect(updatedField).toHaveValue("Proxy One");
expect(screen.getByLabelText("Available models")).toHaveValue("gpt-4");
expect(screen.getByText("Proxy Two")).toBeInTheDocument();
await user.clear(updatedField);
await user.type(updatedField, "Proxy Updated");
await user.click(screen.getByRole("button", { name: "Save Changes" }));
fireEvent.click(screen.getByRole("button", { name: /Advanced: Custom Providers/i }));
await waitFor(() => {
expect(mockedUpdateCustomProvider).toHaveBeenCalledWith("proxy-1", {
name: "Proxy Updated",
apiType: "openai-compatible",
baseUrl: "https://proxy.example.com/v1",
models: [{ id: "gpt-4", name: "gpt-4" }],
});
expect(screen.getByText("load failed")).toBeTruthy();
});
});
it("deletes provider when confirmed", async () => {
mockedFetchCustomProviders.mockResolvedValueOnce([baseProvider] as CustomProvider[] & { providers: CustomProvider[] });
render(<CustomProvidersSection />);
const user = await openDisclosure();
it("shows save error when addCustomProvider fails", async () => {
mockAddCustomProvider.mockRejectedValueOnce(new Error("add failed"));
render(<CustomProvidersSection embedded />);
await user.click(await screen.findByRole("button", { name: "Delete Proxy One" }));
expect(window.confirm).toHaveBeenCalled();
await waitFor(() => expect(mockedDeleteCustomProvider).toHaveBeenCalledWith("proxy-1"));
});
await waitFor(() => {
expect(screen.getByRole("button", { name: /Add Custom Provider/i })).toBeTruthy();
});
it("does not delete provider when confirm is canceled", async () => {
vi.mocked(window.confirm).mockReturnValue(false);
mockedFetchCustomProviders.mockResolvedValueOnce([baseProvider] as CustomProvider[] & { providers: CustomProvider[] });
render(<CustomProvidersSection />);
const user = await openDisclosure();
fireEvent.click(screen.getByRole("button", { name: /Add Custom Provider/i }));
fireEvent.change(screen.getByLabelText("Provider name"), { target: { value: "Test Provider" } });
fireEvent.change(screen.getByLabelText("Base URL"), { target: { value: "https://api.example.com" } });
fireEvent.click(screen.getByRole("button", { name: "Save Provider" }));
await user.click(await screen.findByRole("button", { name: "Delete Proxy One" }));
expect(mockedDeleteCustomProvider).not.toHaveBeenCalled();
});
it("cancel closes form without API calls", async () => {
render(<CustomProvidersSection />);
const user = await openDisclosure();
await user.click(screen.getByRole("button", { name: /Add Custom Provider/ }));
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(screen.queryByLabelText("Provider name")).not.toBeInTheDocument();
expect(mockedAddCustomProvider).not.toHaveBeenCalled();
expect(mockedUpdateCustomProvider).not.toHaveBeenCalled();
});
it("shows API error state on fetch failure", async () => {
mockedFetchCustomProviders.mockRejectedValueOnce(new Error("fetch failed"));
render(<CustomProvidersSection />);
await openDisclosure();
expect(await screen.findByText("fetch failed")).toBeInTheDocument();
});
it("shows API error state on add failure", async () => {
mockedAddCustomProvider.mockRejectedValueOnce(new Error("save failed"));
render(<CustomProvidersSection />);
const user = await openDisclosure();
await user.click(screen.getByRole("button", { name: /Add Custom Provider/ }));
await user.type(screen.getByLabelText("Provider name"), "Proxy Two");
await user.type(screen.getByLabelText("Base URL"), "https://proxy2.example.com/v1");
await user.click(screen.getByRole("button", { name: "Save Provider" }));
expect(await screen.findByText("save failed")).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText("add failed")).toBeTruthy();
});
});
});

View File

@@ -4051,8 +4051,8 @@ describe("ModelOnboardingModal progressive disclosure", () => {
});
});
describe("ModelOnboardingModal custom provider disclosure", () => {
it("renders Add custom provider disclosure collapsed by default", async () => {
describe("Custom providers disclosure", () => {
it("shows add custom provider control on ai-setup and keeps form collapsed initially", async () => {
render(
<ModelOnboardingModal
isOpen
@@ -4062,12 +4062,14 @@ describe("ModelOnboardingModal custom provider disclosure", () => {
/>,
);
fireEvent.click(await screen.findByRole("button", { name: /Advanced provider settings/i }));
expect(await screen.findByText("Set Up AI", { exact: false })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /Advanced provider settings/i }));
expect(await screen.findByText("Add custom provider")).toBeInTheDocument();
expect(screen.queryByTestId("custom-providers-section")).toBeNull();
});
it("expands Add custom provider disclosure on click", async () => {
it("expands custom provider form when add custom provider is clicked", async () => {
render(
<ModelOnboardingModal
isOpen
@@ -4078,11 +4080,12 @@ describe("ModelOnboardingModal custom provider disclosure", () => {
);
fireEvent.click(await screen.findByRole("button", { name: /Advanced provider settings/i }));
fireEvent.click(await screen.findByText("Add custom provider"));
fireEvent.click(await screen.findByRole("button", { name: /Add custom provider/i }));
expect(await screen.findByTestId("custom-providers-section")).toBeInTheDocument();
});
it("refreshes models when custom provider changes", async () => {
it("refreshes auth status and models after custom provider save", async () => {
render(
<ModelOnboardingModal
isOpen
@@ -4093,13 +4096,15 @@ describe("ModelOnboardingModal custom provider disclosure", () => {
);
fireEvent.click(await screen.findByRole("button", { name: /Advanced provider settings/i }));
fireEvent.click(await screen.findByText("Add custom provider"));
fireEvent.click(await screen.findByRole("button", { name: /Add custom provider/i }));
const modelCallsBefore = mockFetchModels.mock.calls.length;
fireEvent.click(await screen.findByTestId("custom-providers-section"));
await waitFor(() => {
expect(mockCreateCustomProvider).toHaveBeenCalled();
expect(mockFetchCustomProviders).toHaveBeenCalled();
expect(mockFetchModels.mock.calls.length).toBeGreaterThan(modelCallsBefore);
});
});

View File

@@ -0,0 +1,349 @@
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from "vitest";
import express from "express";
import type { CustomProvider, TaskStore } from "@fusion/core";
import { createApiRoutes } from "../../routes.js";
import { request } from "../../test-request.js";
const mockCentralListProjects = vi.fn().mockResolvedValue([]);
const mockCentralInit = vi.fn().mockResolvedValue(undefined);
const mockCentralClose = vi.fn().mockResolvedValue(undefined);
const mockCentralReconcileProjectStatuses = vi.fn().mockResolvedValue(undefined);
vi.mock("@fusion/core", async () => {
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
return {
...actual,
isGhAvailable: vi.fn(),
isGhAuthenticated: vi.fn(),
isQmdAvailable: vi.fn().mockResolvedValue(false),
CentralCore: vi.fn().mockImplementation(() => ({
init: mockCentralInit,
close: mockCentralClose,
listProjects: mockCentralListProjects,
reconcileProjectStatuses: mockCentralReconcileProjectStatuses,
})),
};
});
vi.mock("@fusion/engine", () => ({
createFnAgent: vi.fn(async () => ({ session: { state: { messages: [] }, prompt: vi.fn(), dispose: vi.fn() } })),
promptWithFallback: vi.fn(),
}));
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
return {
getTask: vi.fn(),
listTasks: vi.fn().mockResolvedValue([]),
searchTasks: vi.fn().mockResolvedValue([]),
createTask: vi.fn(),
moveTask: vi.fn(),
updateTask: vi.fn(),
deleteTask: vi.fn(),
mergeTask: vi.fn(),
archiveTask: vi.fn(),
unarchiveTask: vi.fn(),
getSettings: vi.fn().mockResolvedValue({}),
getSettingsFast: vi.fn().mockResolvedValue({}),
updateSettings: vi.fn(),
updateGlobalSettings: vi.fn(),
getSettingsByScope: vi.fn().mockResolvedValue({ global: {}, project: {} }),
getSettingsByScopeFast: vi.fn().mockResolvedValue({ global: {}, project: {} }),
getGlobalSettingsStore: vi.fn(),
logEntry: vi.fn().mockResolvedValue(undefined),
getAgentLogs: vi.fn().mockResolvedValue([]),
getAgentLogCount: vi.fn().mockResolvedValue(0),
getAgentLogsByTimeRange: vi.fn().mockResolvedValue([]),
addSteeringComment: vi.fn(),
addTaskComment: vi.fn(),
updateTaskComment: vi.fn(),
deleteTaskComment: vi.fn(),
getTaskDocuments: vi.fn().mockResolvedValue([]),
getTaskDocument: vi.fn().mockResolvedValue(null),
getTaskDocumentRevisions: vi.fn().mockResolvedValue([]),
getAllDocuments: vi.fn().mockResolvedValue([]),
upsertTaskDocument: vi.fn(),
deleteTaskDocument: vi.fn().mockResolvedValue(undefined),
updatePrInfo: vi.fn().mockResolvedValue(undefined),
updateIssueInfo: vi.fn().mockResolvedValue(undefined),
getRootDir: vi.fn().mockReturnValue("/fake/root"),
listWorkflowSteps: vi.fn().mockResolvedValue([]),
createWorkflowStep: vi.fn(),
getWorkflowStep: vi.fn(),
updateWorkflowStep: vi.fn(),
deleteWorkflowStep: vi.fn(),
getMissionStore: vi.fn().mockReturnValue({
listMissions: vi.fn().mockReturnValue([]),
createMission: vi.fn(),
getMissionWithHierarchy: vi.fn(),
updateMission: vi.fn(),
getMission: vi.fn(),
deleteMission: vi.fn(),
listMilestonesByMission: vi.fn().mockReturnValue([]),
createMilestone: vi.fn(),
updateMilestone: vi.fn(),
getMilestone: vi.fn(),
deleteMilestone: vi.fn(),
listTasksByMilestone: vi.fn().mockReturnValue([]),
createMissionTask: vi.fn(),
updateMissionTask: vi.fn(),
getMissionTask: vi.fn(),
deleteMissionTask: vi.fn(),
}),
...overrides,
} as unknown as TaskStore;
}
function createCustomProviderStore(initialCustomProviders: CustomProvider[] = []) {
let customProviders = [...initialCustomProviders];
const globalSettingsStore = {
getSettings: vi.fn().mockImplementation(async () => ({ customProviders })),
updateSettings: vi.fn().mockImplementation(async (updates: { customProviders?: CustomProvider[] }) => {
customProviders = updates.customProviders ?? customProviders;
return { customProviders };
}),
};
const store = createMockStore({
getGlobalSettingsStore: vi.fn().mockReturnValue(globalSettingsStore),
updateGlobalSettings: vi.fn().mockImplementation(async (updates: { customProviders?: CustomProvider[] }) => {
customProviders = updates.customProviders ?? customProviders;
return { customProviders };
}),
});
return { store, globalSettingsStore };
}
function setupApp(store?: TaskStore) {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store ?? createCustomProviderStore().store));
return app;
}
async function doRequest(
app: express.Express,
method: string,
path: string,
body?: unknown,
) {
return request(
app,
method,
path,
body === undefined ? undefined : JSON.stringify(body),
body === undefined ? undefined : { "Content-Type": "application/json" },
);
}
describe("custom providers API routes", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("GET /api/custom-providers returns empty array when none configured", async () => {
const app = setupApp(createCustomProviderStore().store);
const res = await doRequest(app, "GET", "/api/custom-providers");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it("GET /api/custom-providers returns existing providers with masked api keys", async () => {
const app = setupApp(
createCustomProviderStore([
{
id: "cp-1",
name: "Provider One",
apiType: "openai-compatible",
baseUrl: "https://api.example.com/v1",
apiKey: "sk-1234567890",
models: [{ id: "model-1", name: "Model 1" }],
},
{
id: "cp-2",
name: "Provider Two",
apiType: "anthropic-compatible",
baseUrl: "https://anthropic.example.com",
apiKey: "short",
},
]).store,
);
const res = await doRequest(app, "GET", "/api/custom-providers");
expect(res.status).toBe(200);
expect(res.body).toEqual([
expect.objectContaining({
id: "cp-1",
name: "Provider One",
apiType: "openai-compatible",
baseUrl: "https://api.example.com/v1",
models: [{ id: "model-1", name: "Model 1" }],
apiKey: "sk-•••••7890",
}),
expect.objectContaining({
id: "cp-2",
apiKey: "••••••••",
}),
]);
});
it("POST /api/custom-providers creates provider and persists settings", async () => {
const { store } = createCustomProviderStore();
const app = setupApp(store);
const res = await doRequest(app, "POST", "/api/custom-providers", {
name: "My Provider",
apiType: "openai-compatible",
baseUrl: "https://api.example.com/v1",
});
expect(res.status).toBe(201);
expect(res.body).toEqual(
expect.objectContaining({
id: expect.any(String),
name: "My Provider",
apiType: "openai-compatible",
baseUrl: "https://api.example.com/v1",
}),
);
expect(vi.mocked(store.updateGlobalSettings)).toHaveBeenCalledWith({
customProviders: [expect.objectContaining({ name: "My Provider" })],
});
});
it("POST /api/custom-providers rejects missing name", async () => {
const app = setupApp(createCustomProviderStore().store);
const res = await doRequest(app, "POST", "/api/custom-providers", {
apiType: "openai-compatible",
baseUrl: "https://api.example.com/v1",
});
expect(res.status).toBe(400);
expect(String(res.body.error)).toContain("name is required");
});
it("POST /api/custom-providers rejects invalid apiType", async () => {
const app = setupApp(createCustomProviderStore().store);
const res = await doRequest(app, "POST", "/api/custom-providers", {
name: "Bad",
apiType: "invalid",
baseUrl: "https://api.example.com/v1",
});
expect(res.status).toBe(400);
expect(String(res.body.error)).toContain("apiType must be either");
});
it("POST /api/custom-providers rejects invalid baseUrl format", async () => {
const app = setupApp(createCustomProviderStore().store);
const res = await doRequest(app, "POST", "/api/custom-providers", {
name: "Bad URL",
apiType: "openai-compatible",
baseUrl: "not-a-url",
});
expect(res.status).toBe(400);
expect(String(res.body.error)).toContain("baseUrl must be a valid URL");
});
it("POST /api/custom-providers rejects non-http/https baseUrl", async () => {
const app = setupApp(createCustomProviderStore().store);
const res = await doRequest(app, "POST", "/api/custom-providers", {
name: "Bad URL",
apiType: "openai-compatible",
baseUrl: "ftp://example.com",
});
expect(res.status).toBe(400);
expect(String(res.body.error)).toContain("baseUrl must use http or https");
});
it("PUT /api/custom-providers/:id updates existing provider", async () => {
const app = setupApp(
createCustomProviderStore([
{
id: "cp-1",
name: "Provider One",
apiType: "openai-compatible",
baseUrl: "https://api.example.com/v1",
},
]).store,
);
const res = await doRequest(app, "PUT", "/api/custom-providers/cp-1", {
name: "Provider One Updated",
baseUrl: "https://api.updated.example.com/v1",
});
expect(res.status).toBe(200);
expect(res.body).toEqual(
expect.objectContaining({
id: "cp-1",
name: "Provider One Updated",
baseUrl: "https://api.updated.example.com/v1",
}),
);
});
it("PUT /api/custom-providers/:id returns 404 for unknown id", async () => {
const app = setupApp(createCustomProviderStore().store);
const res = await doRequest(app, "PUT", "/api/custom-providers/unknown", {
name: "Updated",
});
expect(res.status).toBe(404);
expect(String(res.body.error)).toContain("not found");
});
it("PUT /api/custom-providers/:id validates baseUrl", async () => {
const app = setupApp(
createCustomProviderStore([
{
id: "cp-1",
name: "Provider One",
apiType: "openai-compatible",
baseUrl: "https://api.example.com/v1",
},
]).store,
);
const res = await doRequest(app, "PUT", "/api/custom-providers/cp-1", {
baseUrl: "ftp://example.com",
});
expect(res.status).toBe(400);
expect(String(res.body.error)).toContain("baseUrl must use http or https");
});
it("DELETE /api/custom-providers/:id removes provider", async () => {
const app = setupApp(
createCustomProviderStore([
{
id: "cp-1",
name: "Provider One",
apiType: "openai-compatible",
baseUrl: "https://api.example.com/v1",
},
]).store,
);
const del = await doRequest(app, "DELETE", "/api/custom-providers/cp-1");
expect(del.status).toBe(200);
expect(del.body).toEqual({ success: true });
const getAfter = await doRequest(app, "GET", "/api/custom-providers");
expect(getAfter.status).toBe(200);
expect(getAfter.body).toEqual([]);
});
it("DELETE /api/custom-providers/:id returns 404 for unknown id", async () => {
const app = setupApp(createCustomProviderStore().store);
const res = await doRequest(app, "DELETE", "/api/custom-providers/unknown");
expect(res.status).toBe(404);
expect(String(res.body.error)).toContain("not found");
});
});