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 { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import { CustomProvidersSection } from "../CustomProvidersSection";
|
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", () => ({
|
vi.mock("../../api", () => ({
|
||||||
fetchCustomProviders: vi.fn(),
|
fetchCustomProviders: (...args: unknown[]) => mockFetchCustomProviders(...args),
|
||||||
addCustomProvider: vi.fn(),
|
addCustomProvider: (...args: unknown[]) => mockAddCustomProvider(...args),
|
||||||
updateCustomProvider: vi.fn(),
|
updateCustomProvider: (...args: unknown[]) => mockUpdateCustomProvider(...args),
|
||||||
deleteCustomProvider: vi.fn(),
|
deleteCustomProvider: (...args: unknown[]) => mockDeleteCustomProvider(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("lucide-react", () => {
|
vi.mock("lucide-react", () => ({
|
||||||
const ForwardRef = (props: React.SVGProps<SVGSVGElement>) => <svg {...props} />;
|
AlertCircle: () => <svg data-testid="icon-alert" />,
|
||||||
return {
|
ChevronRight: () => <svg data-testid="icon-chevron-right" />,
|
||||||
ChevronRight: ForwardRef,
|
Loader2: ({ className }: { className?: string }) => <svg data-testid="icon-loader" className={className} />,
|
||||||
Plus: ForwardRef,
|
Pencil: () => <svg data-testid="icon-pencil" />,
|
||||||
Pencil: ForwardRef,
|
Plus: () => <svg data-testid="icon-plus" />,
|
||||||
Trash2: ForwardRef,
|
Trash2: () => <svg data-testid="icon-trash" />,
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("CustomProvidersSection", () => {
|
describe("CustomProvidersSection", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockedFetchCustomProviders.mockResolvedValue([] as CustomProvider[] & { providers: CustomProvider[] });
|
Object.defineProperty(window, "matchMedia", {
|
||||||
mockedAddCustomProvider.mockResolvedValue(baseProvider);
|
writable: true,
|
||||||
mockedUpdateCustomProvider.mockResolvedValue(baseProvider);
|
value: vi.fn().mockImplementation((query: string) => ({
|
||||||
mockedDeleteCustomProvider.mockResolvedValue({ success: true });
|
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);
|
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||||
});
|
|
||||||
|
|
||||||
it("renders disclosure trigger", () => {
|
render(<CustomProvidersSection embedded />);
|
||||||
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" }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockedAddCustomProvider).toHaveBeenCalledWith({
|
expect(screen.getByLabelText("Delete Test Provider")).toBeTruthy();
|
||||||
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" },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders existing providers", async () => {
|
fireEvent.click(screen.getByLabelText("Delete Test Provider"));
|
||||||
mockedFetchCustomProviders.mockResolvedValueOnce([baseProvider] as CustomProvider[] & { providers: CustomProvider[] });
|
|
||||||
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" }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
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" }],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("deletes provider when confirmed", async () => {
|
|
||||||
mockedFetchCustomProviders.mockResolvedValueOnce([baseProvider] as CustomProvider[] & { providers: CustomProvider[] });
|
|
||||||
render(<CustomProvidersSection />);
|
|
||||||
const user = await openDisclosure();
|
|
||||||
|
|
||||||
await user.click(await screen.findByRole("button", { name: "Delete Proxy One" }));
|
|
||||||
expect(window.confirm).toHaveBeenCalled();
|
expect(window.confirm).toHaveBeenCalled();
|
||||||
await waitFor(() => expect(mockedDeleteCustomProvider).toHaveBeenCalledWith("proxy-1"));
|
expect(mockDeleteCustomProvider).toHaveBeenCalledWith("test-id");
|
||||||
|
expect(screen.getByText("No custom providers configured.")).toBeTruthy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not delete provider when confirm is canceled", async () => {
|
it("shows load error when fetchCustomProviders fails", async () => {
|
||||||
vi.mocked(window.confirm).mockReturnValue(false);
|
mockFetchCustomProviders.mockRejectedValueOnce(new Error("load failed"));
|
||||||
mockedFetchCustomProviders.mockResolvedValueOnce([baseProvider] as CustomProvider[] & { providers: CustomProvider[] });
|
|
||||||
render(<CustomProvidersSection />);
|
render(<CustomProvidersSection />);
|
||||||
const user = await openDisclosure();
|
|
||||||
|
|
||||||
await user.click(await screen.findByRole("button", { name: "Delete Proxy One" }));
|
fireEvent.click(screen.getByRole("button", { name: /Advanced: Custom Providers/i }));
|
||||||
|
|
||||||
expect(mockedDeleteCustomProvider).not.toHaveBeenCalled();
|
await waitFor(() => {
|
||||||
});
|
expect(screen.getByText("load failed")).toBeTruthy();
|
||||||
|
});
|
||||||
it("cancel closes form without API calls", async () => {
|
});
|
||||||
render(<CustomProvidersSection />);
|
|
||||||
const user = await openDisclosure();
|
it("shows save error when addCustomProvider fails", async () => {
|
||||||
await user.click(screen.getByRole("button", { name: /Add Custom Provider/ }));
|
mockAddCustomProvider.mockRejectedValueOnce(new Error("add failed"));
|
||||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
render(<CustomProvidersSection embedded />);
|
||||||
|
|
||||||
expect(screen.queryByLabelText("Provider name")).not.toBeInTheDocument();
|
await waitFor(() => {
|
||||||
expect(mockedAddCustomProvider).not.toHaveBeenCalled();
|
expect(screen.getByRole("button", { name: /Add Custom Provider/i })).toBeTruthy();
|
||||||
expect(mockedUpdateCustomProvider).not.toHaveBeenCalled();
|
});
|
||||||
});
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /Add Custom Provider/i }));
|
||||||
it("shows API error state on fetch failure", async () => {
|
fireEvent.change(screen.getByLabelText("Provider name"), { target: { value: "Test Provider" } });
|
||||||
mockedFetchCustomProviders.mockRejectedValueOnce(new Error("fetch failed"));
|
fireEvent.change(screen.getByLabelText("Base URL"), { target: { value: "https://api.example.com" } });
|
||||||
render(<CustomProvidersSection />);
|
fireEvent.click(screen.getByRole("button", { name: "Save Provider" }));
|
||||||
await openDisclosure();
|
|
||||||
|
await waitFor(() => {
|
||||||
expect(await screen.findByText("fetch failed")).toBeInTheDocument();
|
expect(screen.getByText("add failed")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4051,8 +4051,8 @@ describe("ModelOnboardingModal progressive disclosure", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("ModelOnboardingModal custom provider disclosure", () => {
|
describe("Custom providers disclosure", () => {
|
||||||
it("renders Add custom provider disclosure collapsed by default", async () => {
|
it("shows add custom provider control on ai-setup and keeps form collapsed initially", async () => {
|
||||||
render(
|
render(
|
||||||
<ModelOnboardingModal
|
<ModelOnboardingModal
|
||||||
isOpen
|
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(await screen.findByText("Add custom provider")).toBeInTheDocument();
|
||||||
expect(screen.queryByTestId("custom-providers-section")).toBeNull();
|
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(
|
render(
|
||||||
<ModelOnboardingModal
|
<ModelOnboardingModal
|
||||||
isOpen
|
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.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();
|
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(
|
render(
|
||||||
<ModelOnboardingModal
|
<ModelOnboardingModal
|
||||||
isOpen
|
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.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;
|
const modelCallsBefore = mockFetchModels.mock.calls.length;
|
||||||
|
|
||||||
fireEvent.click(await screen.findByTestId("custom-providers-section"));
|
fireEvent.click(await screen.findByTestId("custom-providers-section"));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockCreateCustomProvider).toHaveBeenCalled();
|
expect(mockCreateCustomProvider).toHaveBeenCalled();
|
||||||
|
expect(mockFetchCustomProviders).toHaveBeenCalled();
|
||||||
expect(mockFetchModels.mock.calls.length).toBeGreaterThan(modelCallsBefore);
|
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