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:
5
.changeset/add-custom-providers.md
Normal file
5
.changeset/add-custom-providers.md
Normal 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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
349
packages/dashboard/src/routes/__tests__/custom-providers.test.ts
Normal file
349
packages/dashboard/src/routes/__tests__/custom-providers.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user