feat(FN-2961): merge fusion/fn-2961

- Add documentation for custom provider settings in the settings reference (`docs/settings-reference.md`)

Commits merged:
- feat(FN-2961): complete Step 6 — document custom provider settings

Files changed:
docs/settings-reference.md | 1 +
 1 file changed, 1 insertion(+)

Fusion-Task-Id: FN-2961
This commit is contained in:
Fusion
2026-04-29 20:39:57 -07:00
committed by gsxdsm
parent 5beb515202
commit 3d7fe18021
7 changed files with 507 additions and 291 deletions

View File

@@ -48,6 +48,7 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`.
| `webhookFormat` | `"slack" \| "discord" \| "generic"` | `"generic"` | Webhook payload format. Part of legacy flat settings. |
| `webhookEvents` | `string[]` | `[]` | Event filter for webhook notifications. Empty/omitted means all events. Part of legacy flat settings. |
| `notificationProviders` | `NotificationProviderConfig[]` | `[]` | Array of pluggable notification provider configurations. Each entry uses `{ id, name, enabled, config }` and is dispatched by provider ID (for example `ntfy` or `webhook`). |
| `customProviders` | `CustomProvider[]` | `[]` | User-defined OpenAI-compatible or Anthropic-compatible providers used by the custom-provider API (`/api/custom-providers`). Each entry uses `{ id, name, apiType, baseUrl, apiKey?, models? }`; API keys are stored raw but masked in API responses. |
| `defaultProjectId` | `string` | `undefined` | Default project for multi-project CLI operations when `--project` is omitted. |
| `setupComplete` | `boolean` | `undefined` | Tracks completion of first-run setup. |
| `favoriteProviders` | `string[]` | `undefined` | Pinned providers shown first in model selectors. |

View File

@@ -1,5 +1,5 @@
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, validateMessageMetadata, normalizeMergeConflictStrategy } from "./types.js";
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskSource, SourceType, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, Settings, GlobalSettings, ProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskSource, SourceType, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, Settings, GlobalSettings, ProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
export { AGENT_VALID_TRANSITIONS } from "./types.js";
export {
BUILTIN_AGENT_PROMPTS,

View File

@@ -29,6 +29,7 @@ export const DEFAULT_GLOBAL_SETTINGS = {
webhookFormat: "generic",
webhookEvents: [],
notificationProviders: [],
customProviders: [],
defaultProjectId: undefined,
setupComplete: undefined,
favoriteProviders: undefined,

View File

@@ -238,6 +238,15 @@ export interface NotificationProviderConfig {
config: Record<string, unknown>;
}
export interface CustomProvider {
id: string;
name: string;
apiType: "openai-compatible" | "anthropic-compatible";
baseUrl: string;
apiKey?: string;
models?: { id: string; name: string }[];
}
export interface WorkflowStepInput {
/** Built-in template source ID when creating a concrete step from a template. */
templateId?: string;
@@ -1235,6 +1244,8 @@ export interface GlobalSettings {
/** Pluggable notification providers configuration. Additive to legacy ntfy
* settings so existing ntfy configuration continues working unchanged. */
notificationProviders?: NotificationProviderConfig[];
/** User-defined OpenAI/Anthropic-compatible API providers. */
customProviders?: CustomProvider[];
/** The default project ID for CLI operations when --project flag is not provided.
* Used to determine which project to operate on when not in a project directory.
* Set via `fn project set-default <name>`. */

View File

@@ -1499,6 +1499,68 @@ export function setClaudeCliEnabled(
});
}
export interface CustomProvider {
id: string;
name: string;
apiType: "openai-compatible" | "anthropic-compatible";
baseUrl: string;
apiKey?: string;
models?: { id: string; name: string }[];
}
export async function fetchCustomProviders(): Promise<CustomProvider[] & { providers: CustomProvider[] }> {
const providers = await api<CustomProvider[]>("/custom-providers");
return Object.assign(providers, { providers });
}
export function addCustomProvider(provider: Omit<CustomProvider, "id">): Promise<CustomProvider> {
return api<CustomProvider>("/custom-providers", {
method: "POST",
body: JSON.stringify(provider),
});
}
export function updateCustomProvider(
id: string,
updates: Partial<Omit<CustomProvider, "id">> | CustomProviderConfig,
): Promise<CustomProvider> {
const legacy = updates as Partial<CustomProviderConfig>;
const normalized: Partial<Omit<CustomProvider, "id">> = {
...(typeof legacy.name === "string" ? { name: legacy.name } : {}),
...(typeof legacy.baseUrl === "string" ? { baseUrl: legacy.baseUrl } : {}),
...(typeof legacy.apiKey === "string" ? { apiKey: legacy.apiKey } : {}),
...(Array.isArray(legacy.models)
? {
models: legacy.models.map((model) => ({
id: model.id,
name: model.name ?? model.id,
})),
}
: {}),
...(legacy.api
? {
apiType: legacy.api === "anthropic-messages" ? "anthropic-compatible" : "openai-compatible",
}
: {}),
...("apiType" in (updates as Record<string, unknown>)
? { apiType: (updates as Partial<Omit<CustomProvider, "id">>).apiType }
: {}),
};
return api<CustomProvider>(`/custom-providers/${encodeURIComponent(id)}`, {
method: "PUT",
body: JSON.stringify(normalized),
});
}
export function deleteCustomProvider(id: string): Promise<{ success: boolean }> {
return api<{ success: boolean }>(`/custom-providers/${encodeURIComponent(id)}`, {
method: "DELETE",
});
}
// Backward-compatibility exports for existing UI callers; will be removed when
// custom-provider UI migrates to the new core CustomProvider contract.
export interface CustomProviderModelInput {
id: string;
name?: string;
@@ -1516,27 +1578,17 @@ export interface CustomProviderConfig {
models: CustomProviderModelInput[];
}
export function fetchCustomProviders(): Promise<{ providers: CustomProviderConfig[] }> {
return api<{ providers: CustomProviderConfig[] }>("/custom-providers");
}
export function createCustomProvider(config: CustomProviderConfig): Promise<{ provider: CustomProviderConfig }> {
return api<{ provider: CustomProviderConfig }>("/custom-providers", {
method: "POST",
body: JSON.stringify(config),
});
}
export function updateCustomProvider(id: string, config: CustomProviderConfig): Promise<{ provider: CustomProviderConfig }> {
return api<{ provider: CustomProviderConfig }>(`/custom-providers/${encodeURIComponent(id)}`, {
method: "PUT",
body: JSON.stringify(config),
});
}
export function deleteCustomProvider(id: string): Promise<void> {
return api<void>(`/custom-providers/${encodeURIComponent(id)}`, {
method: "DELETE",
export function createCustomProvider(config: CustomProviderConfig): Promise<CustomProvider> {
const apiType = config.api === "anthropic-messages" ? "anthropic-compatible" : "openai-compatible";
return addCustomProvider({
name: config.name?.trim() || config.id,
apiType,
baseUrl: config.baseUrl,
apiKey: config.apiKey,
models: config.models?.map((model) => ({
id: model.id,
name: model.name ?? model.id,
})),
});
}

View File

@@ -1,118 +1,275 @@
import { mkdtemp, readFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
// @vitest-environment node
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 { beforeEach, describe, expect, it, vi } from "vitest";
import type { TaskStore, GlobalSettings, CustomProvider } from "@fusion/core";
import { createApiRoutes } from "../../routes.js";
import { request as performRequest } from "../../test-request.js";
function createMockGlobalSettingsStore(settings: GlobalSettings) {
return {
getSettings: vi.fn(async () => settings),
updateSettings: vi.fn(),
getSettingsPath: vi.fn(),
init: vi.fn(),
invalidateCache: vi.fn(),
};
}
function createMockStore(settings: GlobalSettings, onUpdate: (patch: Partial<GlobalSettings>) => void): TaskStore {
const globalSettingsStore = createMockGlobalSettingsStore(settings);
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(async (patch: Partial<GlobalSettings>) => {
onUpdate(patch);
Object.assign(settings, patch);
return settings;
}),
getSettingsByScope: vi.fn().mockResolvedValue({ global: settings, project: {} }),
getSettingsByScopeFast: vi.fn().mockResolvedValue({ global: settings, project: {} }),
getGlobalSettingsStore: vi.fn(() => globalSettingsStore),
logEntry: vi.fn(),
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(),
updatePrInfo: vi.fn(),
updateIssueInfo: vi.fn(),
getRootDir: vi.fn().mockReturnValue("/fake/root"),
getFusionDir: vi.fn().mockReturnValue("/fake/root/.fusion"),
getDatabase: vi.fn(),
listWorkflowSteps: vi.fn().mockResolvedValue([]),
createWorkflowStep: vi.fn(),
getWorkflowStep: vi.fn(),
updateWorkflowStep: vi.fn(),
deleteWorkflowStep: vi.fn(),
getMissionStore: vi.fn(),
} as unknown as TaskStore;
}
async function REQUEST(
app: express.Express,
method: string,
path: string,
body?: unknown,
): Promise<{ status: number; body: any }> {
const payload = body === undefined ? undefined : JSON.stringify(body);
const res = await performRequest(
app,
method,
path,
payload,
body === undefined ? undefined : { "Content-Type": "application/json" },
);
return { status: res.status, body: res.body };
}
function createApp(settings: GlobalSettings, onUpdate: (patch: Partial<GlobalSettings>) => void = () => undefined) {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(createMockStore(settings, onUpdate)));
return app;
}
describe("custom provider routes", () => {
let homeDir: string;
const refresh = vi.fn();
let settings: GlobalSettings;
beforeEach(async () => {
homeDir = await mkdtemp(path.join(os.tmpdir(), "fn-custom-provider-"));
vi.stubEnv("HOME", homeDir);
vi.stubEnv("USERPROFILE", homeDir);
refresh.mockReset();
beforeEach(() => {
settings = {};
});
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("GET /custom-providers returns empty array when none configured", async () => {
const app = createApp(settings);
const res = await REQUEST(app, "GET", "/api/custom-providers");
it("supports create/read/update/delete and refreshes model registry", async () => {
const app = buildApp();
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
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" });
it("GET /custom-providers masks API keys", async () => {
settings.customProviders = [
{
id: "cp-1",
name: "OpenAI Proxy",
apiType: "openai-compatible",
baseUrl: "https://proxy.example.com/v1",
apiKey: "sk-test-secret-key-1234",
},
{
id: "cp-2",
name: "Anthropic Proxy",
apiType: "anthropic-compatible",
baseUrl: "https://anthropic.example.com",
apiKey: "short",
},
];
expect(createRes.status).toBe(201);
expect(refresh).toHaveBeenCalledTimes(1);
const app = createApp(settings);
const res = await REQUEST(app, "GET", "/api/custom-providers");
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");
expect(res.status).toBe(200);
expect(res.body).toEqual([
{
id: "cp-1",
name: "OpenAI Proxy",
apiType: "openai-compatible",
baseUrl: "https://proxy.example.com/v1",
apiKey: "sk-•••••1234",
},
{
id: "cp-2",
name: "Anthropic Proxy",
apiType: "anthropic-compatible",
baseUrl: "https://anthropic.example.com",
apiKey: "••••••••",
},
]);
});
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" });
it("POST /custom-providers creates provider with auto-generated id", async () => {
const updates: Array<Partial<GlobalSettings>> = [];
const app = createApp(settings, (patch) => updates.push(patch));
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 res = await REQUEST(app, "POST", "/api/custom-providers", {
name: "My Provider",
apiType: "openai-compatible",
baseUrl: "https://example.com/v1",
apiKey: "sk-my-secret-5678",
models: [{ id: "gpt-4.1", name: "GPT 4.1" }],
});
const deleteRes = await request(app, "DELETE", "/api/custom-providers/my-openai-proxy");
expect(deleteRes.status).toBe(204);
expect(refresh).toHaveBeenCalledTimes(3);
expect(res.status).toBe(201);
expect(res.body.id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
);
expect(res.body.apiKey).toBe("sk-•••••5678");
expect(updates).toHaveLength(1);
const persisted = updates[0].customProviders as CustomProvider[];
expect(persisted[0]?.apiKey).toBe("sk-my-secret-5678");
});
it("validates bad id, built-in id, invalid URL, and missing fields", async () => {
const app = buildApp();
it("POST /custom-providers rejects missing name", async () => {
const app = createApp(settings);
const res = await REQUEST(app, "POST", "/api/custom-providers", {
apiType: "openai-compatible",
baseUrl: "https://example.com",
});
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);
expect(res.status).toBe(400);
});
it("creates models.json automatically when missing", async () => {
const app = buildApp();
it("POST /custom-providers rejects invalid apiType", async () => {
const app = createApp(settings);
const res = await REQUEST(app, "POST", "/api/custom-providers", {
name: "Invalid",
apiType: "bad-type",
baseUrl: "https://example.com",
});
expect(res.status).toBe(400);
});
it("POST /custom-providers rejects invalid baseUrl", async () => {
const app = createApp(settings);
const res = await REQUEST(app, "POST", "/api/custom-providers", {
name: "Invalid URL",
apiType: "openai-compatible",
baseUrl: "not-a-url",
});
expect(res.status).toBe(400);
});
it("POST /custom-providers rejects non-http/https baseUrl", async () => {
const app = createApp(settings);
const res = await REQUEST(app, "POST", "/api/custom-providers", {
name: "FTP URL",
apiType: "openai-compatible",
baseUrl: "ftp://example.com",
});
expect(res.status).toBe(400);
});
it("PUT /custom-providers/:id updates an existing provider", async () => {
settings.customProviders = [
{
id: "cp-1",
name: "Original",
apiType: "openai-compatible",
baseUrl: "https://original.example.com",
},
];
const app = createApp(settings);
const res = await REQUEST(app, "PUT", "/api/custom-providers/cp-1", {
name: "Updated",
apiKey: "sk-updated-9999",
});
const res = await request(app, "GET", "/api/custom-providers");
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
id: "cp-1",
name: "Updated",
apiType: "openai-compatible",
baseUrl: "https://original.example.com",
apiKey: "sk-•••••9999",
});
});
const modelsPath = path.join(homeDir, ".fusion", "agent", "models.json");
const content = await readFile(modelsPath, "utf8");
expect(JSON.parse(content)).toEqual({ providers: {} });
it("PUT /custom-providers/:id returns 404 for non-existent id", async () => {
const app = createApp(settings);
const res = await REQUEST(app, "PUT", "/api/custom-providers/missing", {
name: "Updated",
});
expect(res.status).toBe(404);
});
it("DELETE /custom-providers/:id removes a provider", async () => {
settings.customProviders = [
{
id: "cp-1",
name: "Delete Me",
apiType: "openai-compatible",
baseUrl: "https://example.com",
},
];
const app = createApp(settings);
const res = await REQUEST(app, "DELETE", "/api/custom-providers/cp-1");
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true });
expect(settings.customProviders).toEqual([]);
});
it("DELETE /custom-providers/:id returns 404 for non-existent id", async () => {
const app = createApp(settings);
const res = await REQUEST(app, "DELETE", "/api/custom-providers/missing");
expect(res.status).toBe(404);
});
});

View File

@@ -1,253 +1,247 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import crypto from "node:crypto";
import type { CustomProvider } from "@fusion/core";
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");
function maskApiKey(key: string): string {
if (key.length <= 8) {
return "••••••••";
}
const normalized = baseUrl.trim();
return key.slice(0, 3) + "•••••" + key.slice(-4);
}
function sanitizeProvider(provider: CustomProvider): CustomProvider {
if (!provider.apiKey) {
return provider;
}
return {
...provider,
apiKey: maskApiKey(provider.apiKey),
};
}
function assertNonEmptyString(value: unknown, fieldName: string): string {
if (typeof value !== "string" || value.trim().length === 0) {
throw badRequest(`${fieldName} is required and must be a non-empty string`);
}
return value.trim();
}
function assertApiType(value: unknown): CustomProvider["apiType"] {
if (value !== "openai-compatible" && value !== "anthropic-compatible") {
throw badRequest("apiType must be either 'openai-compatible' or 'anthropic-compatible'");
}
return value;
}
function assertBaseUrl(value: unknown): string {
const baseUrl = assertNonEmptyString(value, "baseUrl");
let parsed: URL;
try {
parsed = new URL(normalized);
parsed = new URL(baseUrl);
} 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;
return baseUrl;
}
function validateModels(models: unknown): CustomModelConfig[] {
if (!Array.isArray(models) || models.length === 0) {
throw badRequest("models must contain at least one model");
function validateModels(value: unknown): Array<{ id: string; name: string }> | undefined {
if (value === undefined) {
return undefined;
}
return models.map((model, index) => {
if (!model || typeof model !== "object") {
if (!Array.isArray(value)) {
throw badRequest("models must be an array");
}
return value.map((entry, index) => {
if (!entry || typeof entry !== "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;
const row = entry as Record<string, unknown>;
return {
id: assertNonEmptyString(row.id, `models[${index}].id`),
name: assertNonEmptyString(row.name, `models[${index}].name`),
};
});
}
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");
function parseCreateBody(body: unknown): Omit<CustomProvider, "id"> {
if (!body || typeof body !== "object") {
throw badRequest("request body must be an object");
}
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,
const provider: Omit<CustomProvider, "id"> = {
name: assertNonEmptyString(row.name, "name"),
apiType: assertApiType(row.apiType),
baseUrl: assertBaseUrl(row.baseUrl),
};
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: {} };
if (row.apiKey !== undefined) {
if (typeof row.apiKey !== "string") {
throw badRequest("apiKey must be a string");
}
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;
if (row.apiKey.trim().length > 0) {
provider.apiKey = row.apiKey;
}
throw error;
}
const models = validateModels(row.models);
if (models) {
provider.models = models;
}
return provider;
}
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 parseUpdateBody(body: unknown): Partial<Omit<CustomProvider, "id">> {
if (!body || typeof body !== "object") {
throw badRequest("request body must be an object");
}
function normalizeResponse(file: ModelsFile): CustomProviderConfig[] {
return Object.entries(file.providers).map(([id, provider]) => ({ id, ...provider }));
const row = body as Record<string, unknown>;
const updates: Partial<Omit<CustomProvider, "id">> = {};
if (row.name !== undefined) {
updates.name = assertNonEmptyString(row.name, "name");
}
if (row.apiType !== undefined) {
updates.apiType = assertApiType(row.apiType);
}
if (row.baseUrl !== undefined) {
updates.baseUrl = assertBaseUrl(row.baseUrl);
}
if (row.apiKey !== undefined) {
if (typeof row.apiKey !== "string") {
throw badRequest("apiKey must be a string");
}
updates.apiKey = row.apiKey.trim().length > 0 ? row.apiKey : undefined;
}
if (row.models !== undefined) {
updates.models = validateModels(row.models);
}
return updates;
}
export const registerCustomProviderRoutes: ApiRouteRegistrar = (ctx) => {
const { router, options, rethrowAsApiError } = ctx;
const { router, store, rethrowAsApiError } = ctx;
router.get("/custom-providers", async (_req, res) => {
try {
const modelsPath = getFusionModelsPath();
const file = await readModelsFile(modelsPath);
res.json({ providers: normalizeResponse(file) });
if (!store) {
throw new ApiError(500, "Settings store unavailable");
}
const settings = await store.getGlobalSettingsStore().getSettings();
const providers = (settings.customProviders ?? []).map(sanitizeProvider);
res.json(providers);
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
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`);
if (!store) {
throw new ApiError(500, "Settings store unavailable");
}
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,
const providerInput = parseCreateBody(req.body);
const provider: CustomProvider = {
id: crypto.randomUUID(),
...providerInput,
};
await writeModelsFile(modelsPath, file);
options?.modelRegistry?.refresh();
res.status(201).json({ provider });
const settings = await store.getGlobalSettingsStore().getSettings();
const providers = settings.customProviders ?? [];
await store.updateGlobalSettings({ customProviders: [...providers, provider] });
res.status(201).json(sanitizeProvider(provider));
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
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");
if (!store) {
throw new ApiError(500, "Settings store unavailable");
}
const parsed = parseProviderFromBody({ ...req.body, id: providerId });
const modelsPath = getFusionModelsPath();
const file = await readModelsFile(modelsPath);
if (!file.providers[providerId]) {
const providerId = String(req.params.id ?? "").trim();
if (!providerId) {
throw badRequest("id path parameter is required");
}
const updates = parseUpdateBody(req.body);
const settings = await store.getGlobalSettingsStore().getSettings();
const providers = settings.customProviders ?? [];
const targetIndex = providers.findIndex((provider) => provider.id === providerId);
if (targetIndex < 0) {
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,
const updatedProvider: CustomProvider = {
...providers[targetIndex],
...updates,
};
await writeModelsFile(modelsPath, file);
options?.modelRegistry?.refresh();
res.json({ provider: { id: providerId, ...file.providers[providerId] } });
const nextProviders = [...providers];
nextProviders[targetIndex] = updatedProvider;
await store.updateGlobalSettings({ customProviders: nextProviders });
res.json(sanitizeProvider(updatedProvider));
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
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");
if (!store) {
throw new ApiError(500, "Settings store unavailable");
}
const modelsPath = getFusionModelsPath();
const file = await readModelsFile(modelsPath);
if (!file.providers[providerId]) {
const providerId = String(req.params.id ?? "").trim();
if (!providerId) {
throw badRequest("id path parameter is required");
}
const settings = await store.getGlobalSettingsStore().getSettings();
const providers = settings.customProviders ?? [];
const exists = providers.some((provider) => provider.id === providerId);
if (!exists) {
throw notFound(`custom provider '${providerId}' not found`);
}
delete file.providers[providerId];
await writeModelsFile(modelsPath, file);
options?.modelRegistry?.refresh();
res.status(204).end();
const nextProviders = providers.filter((provider) => provider.id !== providerId);
await store.updateGlobalSettings({ customProviders: nextProviders });
res.json({ success: true });
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});