feat(FN-2959): merge fusion/fn-2959-2
- Add custom provider CRUD routes (`POST/GET/PUT/DELETE /api/custom-providers`) with auth validation and storage in `~/.fusion/settings.json`; registers via `register-custom-provider-routes.ts` - Add `CustomProviderForm` component for user-defined provider configuration (endpoint URL, API key, model ID); includes dedicated CSS - Add custom provider section to `SettingsModal` under the "Models" tab with provider list and add/disable controls - Add custom provider selection to `ModelOnboardingModal` onboarding flow so new users can use their own endpoints - Add changeset (`custom-openai-anthropic-providers.md`) for `@runfusion/fusion` minor release - Add unit tests for `CustomProviderForm`, `ModelOnboardingModal`, `SettingsModal`, custom-provider routes, and `useTasks` hook; update existing mocks Commits merged: - feat(FN-2959): complete Step 6 — add changeset and docs - test(FN-2959): update settings modal mocks for custom provider API - feat(FN-2959): complete Step 4 — add onboarding custom provider flow - feat(FN-2959): complete Step 3 — add settings custom provider disclosure - feat(FN-2959): complete Step 2 — add custom provider form component - feat(FN-2959): complete Step 1 — add custom provider CRUD routes - feat(FN-2975): merge fusion/fn-2975 Files changed: .changeset/custom-openai-anthropic-providers.md | 5 + packages/dashboard/app/api/legacy.ts | 41 ++++ .../app/components/CustomProviderForm.css | 45 ++++ .../app/components/CustomProviderForm.tsx | 203 ++++++++++++++++ .../app/components/ModelOnboardingModal.css | 14 ++ .../app/components/ModelOnboardingModal.tsx | 62 ++++- .../dashboard/app/components/SettingsModal.css | 38 +++ .../dashboard/app/components/SettingsModal.tsx | 91 +++++++- .../__tests__/CustomProviderForm.test.tsx | 60 +++++ .../__tests__/ModelOnboardingModal.test.tsx | 23 ++ .../components/__tests__/SettingsModal.test.tsx | 13 ++ .../__tests__/SettingsModalNodeRouting.test.tsx | 4 + .../components/__tests__/settings-mobile.test.tsx | 4 + .../dashboard/app/hooks/__tests__/useTasks.test.ts | 48 ++++ packages/dashboard/app/hooks/useTasks.ts | 4 +- packages/dashboard/src/auth-paths.ts | 4 + packages/dashboard/src/routes.ts | 2 + .../__tests__/custom-provider-routes.test.ts | 118 ++++++++++ .../src/routes/register-custom-provider-routes.ts | 254 +++++++++++++++++++++ 19 files changed, 1027 insertions(+), 6 deletions(-) Fusion-Task-Id: FN-2959
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
import { mkdtemp, readFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import express from "express";
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import type { TaskStore } from "@fusion/core";
|
||||
import { request } from "../../test-request.js";
|
||||
import { createApiRoutes } from "../../routes.js";
|
||||
|
||||
describe("custom provider routes", () => {
|
||||
let homeDir: string;
|
||||
const refresh = vi.fn();
|
||||
|
||||
beforeEach(async () => {
|
||||
homeDir = await mkdtemp(path.join(os.tmpdir(), "fn-custom-provider-"));
|
||||
vi.stubEnv("HOME", homeDir);
|
||||
vi.stubEnv("USERPROFILE", homeDir);
|
||||
refresh.mockReset();
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes({
|
||||
getRootDir: () => "/tmp/project",
|
||||
getFusionDir: () => "/tmp/project/.fusion",
|
||||
getDatabase: () => ({ exec: vi.fn(), prepare: vi.fn().mockReturnValue({ run: vi.fn(), get: vi.fn(), all: vi.fn() }) }),
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
getGlobalSettingsStore: vi.fn().mockReturnValue({ getSettings: vi.fn().mockResolvedValue({}) }),
|
||||
} as unknown as TaskStore, { modelRegistry: { refresh, getAvailable: () => [] } }));
|
||||
return app;
|
||||
}
|
||||
|
||||
it("supports create/read/update/delete and refreshes model registry", async () => {
|
||||
const app = buildApp();
|
||||
|
||||
const createRes = await request(app, "POST", "/api/custom-providers", JSON.stringify({
|
||||
id: "my-openai-proxy",
|
||||
name: "My OpenAI Proxy",
|
||||
baseUrl: "https://proxy.example.com/v1",
|
||||
api: "openai-completions",
|
||||
apiKey: "MY_API_KEY",
|
||||
models: [{ id: "gpt-4o-mini", name: "GPT 4o Mini" }],
|
||||
}), { "Content-Type": "application/json" });
|
||||
|
||||
expect(createRes.status).toBe(201);
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
|
||||
const getRes = await request(app, "GET", "/api/custom-providers");
|
||||
expect(getRes.status).toBe(200);
|
||||
expect((getRes.body as { providers: Array<{ id: string }> }).providers.map((p) => p.id)).toContain("my-openai-proxy");
|
||||
|
||||
const updateRes = await request(app, "PUT", "/api/custom-providers/my-openai-proxy", JSON.stringify({
|
||||
id: "ignored-id",
|
||||
baseUrl: "https://proxy2.example.com/v1",
|
||||
api: "openai-responses",
|
||||
models: [{ id: "gpt-4.1" }],
|
||||
}), { "Content-Type": "application/json" });
|
||||
|
||||
expect(updateRes.status).toBe(200);
|
||||
expect((updateRes.body as { provider: { id: string; baseUrl: string; api: string } }).provider).toMatchObject({
|
||||
id: "my-openai-proxy",
|
||||
baseUrl: "https://proxy2.example.com/v1",
|
||||
api: "openai-responses",
|
||||
});
|
||||
|
||||
const deleteRes = await request(app, "DELETE", "/api/custom-providers/my-openai-proxy");
|
||||
expect(deleteRes.status).toBe(204);
|
||||
expect(refresh).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("validates bad id, built-in id, invalid URL, and missing fields", async () => {
|
||||
const app = buildApp();
|
||||
|
||||
const badId = await request(app, "POST", "/api/custom-providers", JSON.stringify({
|
||||
id: "Bad_ID",
|
||||
baseUrl: "https://proxy.example.com/v1",
|
||||
api: "openai-completions",
|
||||
models: [{ id: "m1" }],
|
||||
}), { "Content-Type": "application/json" });
|
||||
expect(badId.status).toBe(400);
|
||||
|
||||
const builtIn = await request(app, "POST", "/api/custom-providers", JSON.stringify({
|
||||
id: "openai",
|
||||
baseUrl: "https://proxy.example.com/v1",
|
||||
api: "openai-completions",
|
||||
models: [{ id: "m1" }],
|
||||
}), { "Content-Type": "application/json" });
|
||||
expect(builtIn.status).toBe(400);
|
||||
|
||||
const invalidUrl = await request(app, "POST", "/api/custom-providers", JSON.stringify({
|
||||
id: "custom-openai",
|
||||
baseUrl: "ftp://proxy.example.com/v1",
|
||||
api: "openai-completions",
|
||||
models: [{ id: "m1" }],
|
||||
}), { "Content-Type": "application/json" });
|
||||
expect(invalidUrl.status).toBe(400);
|
||||
|
||||
const missingModels = await request(app, "POST", "/api/custom-providers", JSON.stringify({
|
||||
id: "custom-openai",
|
||||
baseUrl: "https://proxy.example.com/v1",
|
||||
api: "openai-completions",
|
||||
models: [],
|
||||
}), { "Content-Type": "application/json" });
|
||||
expect(missingModels.status).toBe(400);
|
||||
});
|
||||
|
||||
it("creates models.json automatically when missing", async () => {
|
||||
const app = buildApp();
|
||||
|
||||
const res = await request(app, "GET", "/api/custom-providers");
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const modelsPath = path.join(homeDir, ".fusion", "agent", "models.json");
|
||||
const content = await readFile(modelsPath, "utf8");
|
||||
expect(JSON.parse(content)).toEqual({ providers: {} });
|
||||
});
|
||||
});
|
||||
254
packages/dashboard/src/routes/register-custom-provider-routes.ts
Normal file
254
packages/dashboard/src/routes/register-custom-provider-routes.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { ApiError, badRequest, notFound } from "../api-error.js";
|
||||
import { getFusionModelsPath } from "../auth-paths.js";
|
||||
import type { ApiRouteRegistrar } from "./types.js";
|
||||
|
||||
const PROVIDER_ID_PATTERN = /^[a-z][a-z0-9-]*$/;
|
||||
const ALLOWED_APIS = new Set([
|
||||
"openai-completions",
|
||||
"openai-responses",
|
||||
"anthropic-messages",
|
||||
"google-generative-ai",
|
||||
]);
|
||||
|
||||
// Keep in sync with BUILT_IN_PROVIDER_IDS in CustomProviderForm.tsx
|
||||
const BUILT_IN_PROVIDER_IDS = new Set<string>([
|
||||
"anthropic", "claude-cli", "pi-claude-cli", "openai", "openai-codex", "google", "gemini", "google-antigravity",
|
||||
"antigravity", "google-vertex", "vertex", "google-cloud-code", "cloud-code", "google-gemini-cli", "google-generative-ai",
|
||||
"ollama", "github", "github-copilot", "openrouter", "minimax", "minimax-cn", "zai", "kimi", "moonshot", "kimi-coding",
|
||||
"bedrock", "amazon-bedrock", "xai", "grok", "opencode", "opencode-go", "qwen", "qwen-ai", "qwen-coder", "alibaba", "tongyi",
|
||||
"lmstudio", "lm-studio", "huggingface", "hugging-face", "hf", "mistral", "mistral-ai", "azure", "azure-openai",
|
||||
"azure-openai-responses", "fireworks", "fireworks-ai", "fireworksai", "cerebras", "groq", "vercel", "vercel-ai-gateway",
|
||||
"hermes", "hermes-agent", "hermesagent", "openclaw", "open-claw", "paperclip", "paperclipai", "paperclip-ai",
|
||||
]);
|
||||
|
||||
type CustomModelConfig = {
|
||||
id: string;
|
||||
name?: string;
|
||||
reasoning?: boolean;
|
||||
contextWindow?: number;
|
||||
maxTokens?: number;
|
||||
};
|
||||
|
||||
type CustomProviderConfig = {
|
||||
id: string;
|
||||
name?: string;
|
||||
baseUrl: string;
|
||||
api: "openai-completions" | "openai-responses" | "anthropic-messages" | "google-generative-ai";
|
||||
apiKey?: string;
|
||||
models: CustomModelConfig[];
|
||||
};
|
||||
|
||||
type ModelsFile = {
|
||||
providers: Record<string, Omit<CustomProviderConfig, "id">>;
|
||||
};
|
||||
|
||||
function validateBaseUrl(baseUrl: unknown): string {
|
||||
if (typeof baseUrl !== "string" || baseUrl.trim().length === 0) {
|
||||
throw badRequest("baseUrl is required");
|
||||
}
|
||||
const normalized = baseUrl.trim();
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(normalized);
|
||||
} catch {
|
||||
throw badRequest("baseUrl must be a valid URL");
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw badRequest("baseUrl must use http or https");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function validateModels(models: unknown): CustomModelConfig[] {
|
||||
if (!Array.isArray(models) || models.length === 0) {
|
||||
throw badRequest("models must contain at least one model");
|
||||
}
|
||||
|
||||
return models.map((model, index) => {
|
||||
if (!model || typeof model !== "object") {
|
||||
throw badRequest(`models[${index}] must be an object`);
|
||||
}
|
||||
const row = model as Record<string, unknown>;
|
||||
if (typeof row.id !== "string" || row.id.trim().length === 0) {
|
||||
throw badRequest(`models[${index}].id is required`);
|
||||
}
|
||||
const parsed: CustomModelConfig = { id: row.id.trim() };
|
||||
if (typeof row.name === "string" && row.name.trim().length > 0) parsed.name = row.name.trim();
|
||||
if (typeof row.reasoning === "boolean") parsed.reasoning = row.reasoning;
|
||||
if (row.contextWindow !== undefined) {
|
||||
if (typeof row.contextWindow !== "number" || !Number.isFinite(row.contextWindow) || row.contextWindow <= 0) {
|
||||
throw badRequest(`models[${index}].contextWindow must be a positive number`);
|
||||
}
|
||||
parsed.contextWindow = row.contextWindow;
|
||||
}
|
||||
if (row.maxTokens !== undefined) {
|
||||
if (typeof row.maxTokens !== "number" || !Number.isFinite(row.maxTokens) || row.maxTokens <= 0) {
|
||||
throw badRequest(`models[${index}].maxTokens must be a positive number`);
|
||||
}
|
||||
parsed.maxTokens = row.maxTokens;
|
||||
}
|
||||
return parsed;
|
||||
});
|
||||
}
|
||||
|
||||
function validateApi(api: unknown): CustomProviderConfig["api"] {
|
||||
if (typeof api !== "string" || !ALLOWED_APIS.has(api)) {
|
||||
throw badRequest("api must be one of: openai-completions, openai-responses, anthropic-messages, google-generative-ai");
|
||||
}
|
||||
return api as CustomProviderConfig["api"];
|
||||
}
|
||||
|
||||
function parseProviderFromBody(body: unknown): CustomProviderConfig {
|
||||
if (!body || typeof body !== "object") throw badRequest("request body must be an object");
|
||||
const row = body as Record<string, unknown>;
|
||||
|
||||
if (typeof row.id !== "string" || row.id.trim().length === 0) {
|
||||
throw badRequest("id is required");
|
||||
}
|
||||
const id = row.id.trim();
|
||||
if (!PROVIDER_ID_PATTERN.test(id)) {
|
||||
throw badRequest("id must be kebab-case (^[a-z][a-z0-9-]*$)");
|
||||
}
|
||||
|
||||
const baseUrl = validateBaseUrl(row.baseUrl);
|
||||
const api = validateApi(row.api);
|
||||
const models = validateModels(row.models);
|
||||
|
||||
const config: CustomProviderConfig = {
|
||||
id,
|
||||
baseUrl,
|
||||
api,
|
||||
models,
|
||||
};
|
||||
|
||||
if (typeof row.name === "string" && row.name.trim().length > 0) config.name = row.name.trim();
|
||||
if (typeof row.apiKey === "string" && row.apiKey.trim().length > 0) config.apiKey = row.apiKey.trim();
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
async function readModelsFile(modelsPath: string): Promise<ModelsFile> {
|
||||
try {
|
||||
const content = await readFile(modelsPath, "utf8");
|
||||
const parsed = JSON.parse(content) as Partial<ModelsFile>;
|
||||
if (!parsed || typeof parsed !== "object" || !parsed.providers || typeof parsed.providers !== "object") {
|
||||
return { providers: {} };
|
||||
}
|
||||
return { providers: parsed.providers as Record<string, Omit<CustomProviderConfig, "id">> };
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
await mkdir(path.dirname(modelsPath), { recursive: true });
|
||||
const initial = { providers: {} } satisfies ModelsFile;
|
||||
await writeFile(modelsPath, `${JSON.stringify(initial, null, 2)}\n`, "utf8");
|
||||
return initial;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeModelsFile(modelsPath: string, file: ModelsFile): Promise<void> {
|
||||
await mkdir(path.dirname(modelsPath), { recursive: true });
|
||||
await writeFile(modelsPath, `${JSON.stringify(file, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function normalizeResponse(file: ModelsFile): CustomProviderConfig[] {
|
||||
return Object.entries(file.providers).map(([id, provider]) => ({ id, ...provider }));
|
||||
}
|
||||
|
||||
export const registerCustomProviderRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
const { router, options, rethrowAsApiError } = ctx;
|
||||
|
||||
router.get("/custom-providers", async (_req, res) => {
|
||||
try {
|
||||
const modelsPath = getFusionModelsPath();
|
||||
const file = await readModelsFile(modelsPath);
|
||||
res.json({ providers: normalizeResponse(file) });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/custom-providers", async (req, res) => {
|
||||
try {
|
||||
const provider = parseProviderFromBody(req.body);
|
||||
if (BUILT_IN_PROVIDER_IDS.has(provider.id)) {
|
||||
throw badRequest(`id '${provider.id}' is reserved for a built-in provider`);
|
||||
}
|
||||
|
||||
const modelsPath = getFusionModelsPath();
|
||||
const file = await readModelsFile(modelsPath);
|
||||
if (file.providers[provider.id]) {
|
||||
throw badRequest(`custom provider '${provider.id}' already exists`);
|
||||
}
|
||||
|
||||
file.providers[provider.id] = {
|
||||
name: provider.name,
|
||||
baseUrl: provider.baseUrl,
|
||||
api: provider.api,
|
||||
apiKey: provider.apiKey,
|
||||
models: provider.models,
|
||||
};
|
||||
await writeModelsFile(modelsPath, file);
|
||||
options?.modelRegistry?.refresh();
|
||||
|
||||
res.status(201).json({ provider });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.put("/custom-providers/:id", async (req, res) => {
|
||||
try {
|
||||
const providerId = String(req.params.id ?? "").trim();
|
||||
if (!providerId) throw badRequest("id path parameter is required");
|
||||
|
||||
const parsed = parseProviderFromBody({ ...req.body, id: providerId });
|
||||
const modelsPath = getFusionModelsPath();
|
||||
const file = await readModelsFile(modelsPath);
|
||||
if (!file.providers[providerId]) {
|
||||
throw notFound(`custom provider '${providerId}' not found`);
|
||||
}
|
||||
|
||||
file.providers[providerId] = {
|
||||
name: parsed.name,
|
||||
baseUrl: parsed.baseUrl,
|
||||
api: parsed.api,
|
||||
apiKey: parsed.apiKey,
|
||||
models: parsed.models,
|
||||
};
|
||||
await writeModelsFile(modelsPath, file);
|
||||
options?.modelRegistry?.refresh();
|
||||
|
||||
res.json({ provider: { id: providerId, ...file.providers[providerId] } });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete("/custom-providers/:id", async (req, res) => {
|
||||
try {
|
||||
const providerId = String(req.params.id ?? "").trim();
|
||||
if (!providerId) throw badRequest("id path parameter is required");
|
||||
|
||||
const modelsPath = getFusionModelsPath();
|
||||
const file = await readModelsFile(modelsPath);
|
||||
if (!file.providers[providerId]) {
|
||||
throw notFound(`custom provider '${providerId}' not found`);
|
||||
}
|
||||
|
||||
delete file.providers[providerId];
|
||||
await writeModelsFile(modelsPath, file);
|
||||
options?.modelRegistry?.refresh();
|
||||
|
||||
res.status(204).end();
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user