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:
Fusion
2026-04-29 20:12:57 -07:00
committed by gsxdsm
parent 0b14a6fea1
commit 19cdf7f448
17 changed files with 977 additions and 4 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Add dashboard support for managing custom OpenAI/Anthropic/Google-compatible providers via Settings and onboarding advanced sections, backed by new custom-provider API routes and models.json persistence.

View File

@@ -1499,6 +1499,47 @@ export function setClaudeCliEnabled(
});
}
export interface CustomProviderModelInput {
id: string;
name?: string;
reasoning?: boolean;
contextWindow?: number;
maxTokens?: number;
}
export interface CustomProviderConfig {
id: string;
name?: string;
baseUrl: string;
api: "openai-completions" | "openai-responses" | "anthropic-messages" | "google-generative-ai";
apiKey?: string;
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",
});
}
/** Fetch authentication status for all OAuth providers */
export function fetchAuthStatus(): Promise<{
providers: AuthProvider[];

View File

@@ -0,0 +1,45 @@
.custom-provider-form {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.custom-provider-form__group {
padding: 0;
margin: 0;
}
.custom-provider-form__models {
display: flex;
flex-direction: column;
gap: var(--space-sm);
margin-bottom: var(--space-sm);
}
.custom-provider-form__model-row {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr)) auto;
gap: var(--space-sm);
align-items: center;
}
.custom-provider-form__toggle {
justify-content: flex-start;
}
.custom-provider-form__actions {
display: flex;
justify-content: flex-end;
gap: var(--space-sm);
}
@media (max-width: 768px) {
.custom-provider-form__model-row {
grid-template-columns: 1fr;
}
.custom-provider-form__actions {
justify-content: stretch;
flex-direction: column;
}
}

View File

@@ -0,0 +1,203 @@
import { useMemo, useState } from "react";
import type { CustomProviderConfig, CustomProviderModelInput } from "../api";
import "./CustomProviderForm.css";
// Keep in sync with BUILT_IN_PROVIDER_IDS in register-custom-provider-routes.ts
export 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",
]);
const PROVIDER_ID_PATTERN = /^[a-z][a-z0-9-]*$/;
const API_TYPES: CustomProviderConfig["api"][] = [
"openai-completions",
"openai-responses",
"anthropic-messages",
"google-generative-ai",
];
type Props = {
initialConfig?: CustomProviderConfig;
onSave: (config: CustomProviderConfig) => void | Promise<void>;
onCancel?: () => void;
saving?: boolean;
error?: string;
};
function emptyModel(): CustomProviderModelInput {
return { id: "", name: "", reasoning: false };
}
export function CustomProviderForm({ initialConfig, onSave, onCancel, saving = false, error }: Props) {
const editing = Boolean(initialConfig);
const [id, setId] = useState(initialConfig?.id ?? "");
const [name, setName] = useState(initialConfig?.name ?? "");
const [baseUrl, setBaseUrl] = useState(initialConfig?.baseUrl ?? "");
const [api, setApi] = useState<CustomProviderConfig["api"]>(initialConfig?.api ?? "openai-completions");
const [apiKey, setApiKey] = useState(initialConfig?.apiKey ?? "");
const [models, setModels] = useState<CustomProviderModelInput[]>(initialConfig?.models?.length ? initialConfig.models : [emptyModel()]);
const [validationError, setValidationError] = useState<string | null>(null);
const canRemoveModel = models.length > 1;
const mergedError = useMemo(() => validationError ?? error ?? null, [validationError, error]);
function updateModel(index: number, patch: Partial<CustomProviderModelInput>) {
setModels((prev) => prev.map((model, i) => (i === index ? { ...model, ...patch } : model)));
}
function removeModel(index: number) {
setModels((prev) => (prev.length <= 1 ? prev : prev.filter((_, i) => i !== index)));
}
function validate(): string | null {
if (!id.trim()) return "Provider ID is required.";
if (!PROVIDER_ID_PATTERN.test(id.trim())) return "Provider ID must be kebab-case.";
if (!editing && BUILT_IN_PROVIDER_IDS.has(id.trim())) return "Provider ID conflicts with a built-in provider.";
if (!baseUrl.trim()) return "Base URL is required.";
try {
const parsed = new URL(baseUrl.trim());
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return "Base URL must use http or https.";
}
} catch {
return "Base URL must be a valid URL.";
}
if (!API_TYPES.includes(api)) return "API type is required.";
if (models.length === 0) return "At least one model is required.";
if (models.some((model) => !model.id?.trim())) return "Each model must have a model ID.";
return null;
}
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
const message = validate();
setValidationError(message);
if (message) return;
await onSave({
id: id.trim(),
name: name.trim() || undefined,
baseUrl: baseUrl.trim(),
api,
apiKey: apiKey.trim() || undefined,
models: models.map((model) => ({
id: model.id.trim(),
name: model.name?.trim() || undefined,
reasoning: Boolean(model.reasoning),
contextWindow: model.contextWindow,
maxTokens: model.maxTokens,
})),
});
}
return (
<form onSubmit={onSubmit} className="custom-provider-form" aria-label="custom-provider-form">
<div className="form-group custom-provider-form__group">
<label htmlFor="custom-provider-id">Provider ID</label>
<input id="custom-provider-id" className="input" value={id} onChange={(e) => setId(e.target.value)} disabled={editing || saving} />
</div>
<div className="form-group custom-provider-form__group">
<label htmlFor="custom-provider-name">Display Name</label>
<input id="custom-provider-name" className="input" value={name} onChange={(e) => setName(e.target.value)} disabled={saving} />
</div>
<div className="form-group custom-provider-form__group">
<label htmlFor="custom-provider-base-url">Base URL</label>
<input id="custom-provider-base-url" className="input" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} disabled={saving} />
</div>
<div className="form-group custom-provider-form__group">
<label htmlFor="custom-provider-api">API Type</label>
<select id="custom-provider-api" className="select" value={api} onChange={(e) => setApi(e.target.value as CustomProviderConfig["api"])} disabled={saving}>
{API_TYPES.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
</div>
<div className="form-group custom-provider-form__group">
<label htmlFor="custom-provider-api-key">API Key</label>
<input id="custom-provider-api-key" className="input" placeholder="sk-..., MY_API_KEY, or !command" value={apiKey} onChange={(e) => setApiKey(e.target.value)} disabled={saving} />
</div>
<div className="form-group custom-provider-form__group">
<label>Models</label>
<div className="custom-provider-form__models">
{models.map((model, index) => (
<div key={`${index}-model`} className="custom-provider-form__model-row">
<input
className="input"
aria-label={`Model ID ${index + 1}`}
placeholder="Model ID"
value={model.id}
onChange={(e) => updateModel(index, { id: e.target.value })}
disabled={saving}
/>
<input
className="input"
aria-label={`Model name ${index + 1}`}
placeholder="Display name"
value={model.name ?? ""}
onChange={(e) => updateModel(index, { name: e.target.value })}
disabled={saving}
/>
<label className="checkbox-label custom-provider-form__toggle">
<input
type="checkbox"
checked={Boolean(model.reasoning)}
onChange={(e) => updateModel(index, { reasoning: e.target.checked })}
disabled={saving}
/>
Reasoning
</label>
<input
className="input"
aria-label={`Context window ${index + 1}`}
placeholder="Context window"
type="number"
value={model.contextWindow ?? ""}
onChange={(e) => updateModel(index, { contextWindow: e.target.value ? Number(e.target.value) : undefined })}
disabled={saving}
/>
<input
className="input"
aria-label={`Max tokens ${index + 1}`}
placeholder="Max tokens"
type="number"
value={model.maxTokens ?? ""}
onChange={(e) => updateModel(index, { maxTokens: e.target.value ? Number(e.target.value) : undefined })}
disabled={saving}
/>
<button
type="button"
className="btn btn-icon btn-sm"
onClick={() => removeModel(index)}
disabled={saving || !canRemoveModel}
aria-label={`Remove model ${index + 1}`}
>
×
</button>
</div>
))}
</div>
<button type="button" className="btn btn-sm" onClick={() => setModels((prev) => [...prev, emptyModel()])} disabled={saving}>
+ Add model
</button>
</div>
{mergedError ? <div className="form-error">{mergedError}</div> : null}
<div className="custom-provider-form__actions">
{onCancel ? <button type="button" className="btn" onClick={onCancel} disabled={saving}>Cancel</button> : null}
<button type="submit" className="btn btn-primary" disabled={saving}>{saving ? "Saving..." : "Save Provider"}</button>
</div>
</form>
);
}

View File

@@ -1495,3 +1495,17 @@
width: 100%;
}
}
.onboarding-custom-provider-list {
display: flex;
flex-direction: column;
gap: var(--space-xs);
margin: var(--space-sm) 0;
}
.onboarding-custom-provider-item {
display: flex;
align-items: center;
gap: var(--space-sm);
color: var(--text-muted);
}

View File

@@ -2,7 +2,7 @@ import "./ModelOnboardingModal.css";
import { useState, useEffect, useCallback, useRef } from "react";
import { X, Loader2, CheckCircle, Key, Zap, GitPullRequest, Rocket, Plus, ChevronRight } from "lucide-react";
import type { Task } from "@fusion/core";
import type { AuthProvider, ModelInfo } from "../api";
import type { AuthProvider, ModelInfo, CustomProviderConfig } from "../api";
import {
fetchAuthStatus,
fetchGlobalSettings,
@@ -13,6 +13,8 @@ import {
fetchModels,
updateGlobalSettings,
createTask,
fetchCustomProviders,
createCustomProvider,
} from "../api";
import type { ToastType } from "../hooks/useToast";
import { useModalResizePersist } from "../hooks/useModalResizePersist";
@@ -20,6 +22,7 @@ import { CustomModelDropdown } from "./CustomModelDropdown";
import { ProviderIcon } from "./ProviderIcon";
import { ClaudeCliProviderCard } from "./ClaudeCliProviderCard";
import { LoginInstructions } from "./LoginInstructions";
import { CustomProviderForm } from "./CustomProviderForm";
import { appendTokenQuery } from "../auth";
/** Provider-specific API key setup metadata for onboarding form rendering */
@@ -593,6 +596,10 @@ export function ModelOnboardingModal({
const [apiKeyInputs, setApiKeyInputs] = useState<Record<string, string>>({});
const [apiKeyErrors, setApiKeyErrors] = useState<Record<string, string>>({});
const [apiKeySuccess, setApiKeySuccess] = useState<Record<string, string | null>>({});
const [customProviders, setCustomProviders] = useState<CustomProviderConfig[]>([]);
const [showCustomProviderForm, setShowCustomProviderForm] = useState(false);
const [customProviderSaving, setCustomProviderSaving] = useState(false);
const [customProviderError, setCustomProviderError] = useState<string | undefined>();
const apiKeySuccessTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
const onboardingContentRef = useRef<HTMLDivElement | null>(null);
const modalRef = useRef<HTMLDivElement | null>(null);
@@ -736,14 +743,41 @@ export function ModelOnboardingModal({
}
}, []);
const loadCustomProviders = useCallback(async () => {
try {
const data = await fetchCustomProviders();
setCustomProviders(data.providers ?? []);
} catch {
// best effort
}
}, []);
const handleSaveCustomProvider = useCallback(async (config: CustomProviderConfig) => {
setCustomProviderSaving(true);
setCustomProviderError(undefined);
try {
await createCustomProvider(config);
await loadCustomProviders();
await fetchModels().then((response) => setAvailableModels(response.models ?? []));
setShowCustomProviderForm(false);
} catch (err) {
setCustomProviderError(getErrorMessage(err) || "Failed to create custom provider");
} finally {
setCustomProviderSaving(false);
}
}, [loadCustomProviders]);
// Reload auth status when returning to AI Setup step from another step (not on initial mount)
const aiSetupReturnRef = useRef(false);
useEffect(() => {
if (step === "ai-setup") {
void loadCustomProviders();
}
if (aiSetupReturnRef.current) {
loadAuthStatus();
}
aiSetupReturnRef.current = step !== "ai-setup";
}, [step, loadAuthStatus]);
}, [step, loadAuthStatus, loadCustomProviders]);
// OAuth status for the GitHub provider (used for OAuth-specific controls like Connect/Disconnect).
const githubProvider = authProviders.find((p) => p.id === "github");
@@ -2011,6 +2045,30 @@ export function ModelOnboardingModal({
All currently available providers are already shown above.
</p>
)}
{customProviders.length > 0 ? (
<div className="onboarding-custom-provider-list">
{customProviders.map((provider) => (
<div key={provider.id} className="onboarding-custom-provider-item">
<ProviderIcon provider={provider.id} size="sm" />
<span>{provider.name || provider.id}</span>
</div>
))}
</div>
) : null}
{!showCustomProviderForm ? (
<button type="button" className="btn btn-sm" onClick={() => setShowCustomProviderForm(true)}>
Add custom provider
</button>
) : (
<CustomProviderForm
onSave={handleSaveCustomProvider}
onCancel={() => { setShowCustomProviderForm(false); setCustomProviderError(undefined); }}
saving={customProviderSaving}
error={customProviderError}
/>
)}
</div>
</OnboardingDisclosure>

View File

@@ -1571,3 +1571,41 @@
font-size: 12px;
color: var(--text-muted);
}
.auth-advanced-disclosure {
margin-top: var(--space-md);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
}
.auth-advanced-disclosure > summary {
cursor: pointer;
padding: var(--space-sm) var(--space-md);
}
.auth-advanced-content {
display: flex;
flex-direction: column;
gap: var(--space-sm);
padding: 0 var(--space-md) var(--space-md);
}
.auth-custom-provider-item {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-sm);
}
.auth-custom-provider-actions {
display: flex;
gap: var(--space-sm);
}
@media (max-width: 768px) {
.auth-custom-provider-item {
flex-direction: column;
align-items: flex-start;
}
}

View File

@@ -10,8 +10,8 @@ import {
resolveTitleSummarizerSettingsModel,
} from "@fusion/core";
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset, NtfyNotificationEvent, AgentPromptsConfig, ThinkingLevel } from "@fusion/core";
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotesDetailed, fetchDashboardHealth, checkForUpdates, fetchRemoteSettings, updateRemoteSettings, fetchRemoteStatus, installCloudflared, startRemoteTunnel, stopRemoteTunnel, regenerateRemotePersistentToken, generateShortLivedRemoteToken, fetchRemoteQr, fetchRemoteUrl } from "../api";
import type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemoteDetailed, RemoteSettings, RemoteStatus, UpdateCheckResponse } from "../api";
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotesDetailed, fetchDashboardHealth, checkForUpdates, fetchRemoteSettings, updateRemoteSettings, fetchRemoteStatus, installCloudflared, startRemoteTunnel, stopRemoteTunnel, regenerateRemotePersistentToken, generateShortLivedRemoteToken, fetchRemoteQr, fetchRemoteUrl, fetchCustomProviders, createCustomProvider, updateCustomProvider, deleteCustomProvider } from "../api";
import type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemoteDetailed, RemoteSettings, RemoteStatus, UpdateCheckResponse, CustomProviderConfig } from "../api";
import { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
import type { ToastType } from "../hooks/useToast";
@@ -32,6 +32,7 @@ import { PluginSlot } from "./PluginSlot";
import { AgentPromptsManager } from "./AgentPromptsManager";
import { LoginInstructions } from "./LoginInstructions";
import { ProviderIcon } from "./ProviderIcon";
import { CustomProviderForm } from "./CustomProviderForm";
import { applyPresetToSelection, generateUniquePresetId } from "../utils/modelPresets";
import { appendTokenQuery } from "../auth";
import { useConfirm } from "../hooks/useConfirm";
@@ -405,6 +406,11 @@ export function SettingsModal({
const [loginInstructions, setLoginInstructions] = useState<Record<string, string>>({});
const [apiKeyInputs, setApiKeyInputs] = useState<Record<string, string>>({});
const [apiKeyErrors, setApiKeyErrors] = useState<Record<string, string>>({});
const [customProviders, setCustomProviders] = useState<CustomProviderConfig[]>([]);
const [customProviderEditing, setCustomProviderEditing] = useState<CustomProviderConfig | null>(null);
const [showCustomProviderForm, setShowCustomProviderForm] = useState(false);
const [customProviderSaving, setCustomProviderSaving] = useState(false);
const [customProviderError, setCustomProviderError] = useState<string | undefined>();
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Model state
@@ -780,6 +786,7 @@ export function SettingsModal({
if (activeSection === "authentication") {
setAuthLoading(true);
loadAuthStatus().finally(() => setAuthLoading(false));
void fetchCustomProviders().then((data) => setCustomProviders(data.providers ?? [])).catch(() => undefined);
}
// Clean up polling when leaving auth section
return () => {
@@ -790,6 +797,49 @@ export function SettingsModal({
};
}, [activeSection, loadAuthStatus]);
const loadCustomProviders = useCallback(async () => {
const data = await fetchCustomProviders();
setCustomProviders(data.providers ?? []);
}, []);
const handleSaveCustomProvider = useCallback(async (config: CustomProviderConfig) => {
setCustomProviderSaving(true);
setCustomProviderError(undefined);
try {
if (customProviderEditing) {
await updateCustomProvider(customProviderEditing.id, config);
} else {
await createCustomProvider(config);
}
await loadCustomProviders();
await fetchModels();
setShowCustomProviderForm(false);
setCustomProviderEditing(null);
} catch (err) {
setCustomProviderError(getErrorMessage(err) || "Failed to save custom provider");
} finally {
setCustomProviderSaving(false);
}
}, [customProviderEditing, loadCustomProviders]);
const handleDeleteCustomProvider = useCallback(async (provider: CustomProviderConfig) => {
const ok = await confirm(`Delete custom provider '${provider.id}'?`, {
title: "Delete custom provider",
confirmText: "Delete",
cancelText: "Cancel",
danger: true,
});
if (!ok) return;
try {
await deleteCustomProvider(provider.id);
await loadCustomProviders();
await fetchModels();
} catch (err) {
addToast(getErrorMessage(err) || "Failed to delete custom provider", "error");
}
}, [addToast, confirm, loadCustomProviders]);
const scrollSettingsToTop = useCallback(() => {
settingsContentRef.current?.scrollTo({ top: 0, behavior: "smooth" });
}, []);
@@ -4638,6 +4688,43 @@ export function SettingsModal({
</div>
)}
<details className="auth-advanced-disclosure">
<summary>Advanced Custom Providers</summary>
<div className="auth-advanced-content">
<div className="auth-custom-provider-list">
{customProviders.map((provider) => (
<div key={provider.id} className="auth-custom-provider-item">
<div>
<strong>{provider.name || provider.id}</strong>
<small className="settings-muted">{provider.id}</small>
</div>
<div className="auth-custom-provider-actions">
<button type="button" className="btn btn-sm" onClick={() => { setCustomProviderEditing(provider); setShowCustomProviderForm(true); }}>Edit</button>
<button type="button" className="btn btn-sm" onClick={() => { void handleDeleteCustomProvider(provider); }}>Delete</button>
</div>
</div>
))}
</div>
{!showCustomProviderForm ? (
<button type="button" className="btn btn-sm" onClick={() => { setCustomProviderEditing(null); setShowCustomProviderForm(true); }}>
Add Custom Provider
</button>
) : (
<CustomProviderForm
initialConfig={customProviderEditing ?? undefined}
onSave={handleSaveCustomProvider}
onCancel={() => {
setShowCustomProviderForm(false);
setCustomProviderEditing(null);
setCustomProviderError(undefined);
}}
saving={customProviderSaving}
error={customProviderError}
/>
)}
</div>
</details>
</>
);
}

View File

@@ -0,0 +1,60 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { CustomProviderForm } from "../CustomProviderForm";
describe("CustomProviderForm", () => {
it("renders base fields", () => {
render(<CustomProviderForm onSave={vi.fn()} />);
expect(screen.getByLabelText("Provider ID")).toBeInTheDocument();
expect(screen.getByLabelText("Display Name")).toBeInTheDocument();
expect(screen.getByLabelText("Base URL")).toBeInTheDocument();
expect(screen.getByLabelText("API Type")).toBeInTheDocument();
expect(screen.getByLabelText("API Key")).toBeInTheDocument();
});
it("validates required fields and rejects built-in IDs", async () => {
const user = userEvent.setup();
render(<CustomProviderForm onSave={vi.fn()} />);
await user.click(screen.getByRole("button", { name: "Save Provider" }));
expect(screen.getByText("Provider ID is required.")).toBeInTheDocument();
await user.type(screen.getByLabelText("Provider ID"), "openai");
await user.type(screen.getByLabelText("Base URL"), "https://proxy.example.com/v1");
await user.type(screen.getByLabelText("Model ID 1"), "gpt-4o-mini");
await user.click(screen.getByRole("button", { name: "Save Provider" }));
expect(screen.getByText("Provider ID conflicts with a built-in provider.")).toBeInTheDocument();
});
it("submits valid config", async () => {
const user = userEvent.setup();
const onSave = vi.fn().mockResolvedValue(undefined);
render(<CustomProviderForm onSave={onSave} />);
await user.type(screen.getByLabelText("Provider ID"), "my-proxy");
await user.type(screen.getByLabelText("Display Name"), "My Proxy");
await user.type(screen.getByLabelText("Base URL"), "https://proxy.example.com/v1");
await user.selectOptions(screen.getByLabelText("API Type"), "openai-responses");
await user.type(screen.getByLabelText("API Key"), "MY_API_KEY");
await user.type(screen.getByLabelText("Model ID 1"), "gpt-4.1-mini");
await user.type(screen.getByLabelText("Model name 1"), "GPT 4.1 Mini");
await user.click(screen.getByRole("button", { name: "Save Provider" }));
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
id: "my-proxy",
name: "My Proxy",
baseUrl: "https://proxy.example.com/v1",
api: "openai-responses",
apiKey: "MY_API_KEY",
models: [expect.objectContaining({ id: "gpt-4.1-mini", name: "GPT 4.1 Mini" })],
}));
});
it("shows external error state", () => {
render(<CustomProviderForm onSave={vi.fn()} error="Request failed" />);
expect(screen.getByText("Request failed")).toBeInTheDocument();
});
});

View File

@@ -15,6 +15,8 @@ const mockFetchModels = vi.fn();
const mockFetchGlobalSettings = vi.fn();
const mockUpdateGlobalSettings = vi.fn();
const mockCreateTask = vi.fn();
const mockFetchCustomProviders = vi.fn();
const mockCreateCustomProvider = vi.fn();
vi.mock("../../api", () => ({
fetchAuthStatus: (...args: unknown[]) => mockFetchAuthStatus(...args),
@@ -26,6 +28,8 @@ vi.mock("../../api", () => ({
fetchGlobalSettings: (...args: unknown[]) => mockFetchGlobalSettings(...args),
updateGlobalSettings: (...args: unknown[]) => mockUpdateGlobalSettings(...args),
createTask: (...args: unknown[]) => mockCreateTask(...args),
fetchCustomProviders: (...args: unknown[]) => mockFetchCustomProviders(...args),
createCustomProvider: (...args: unknown[]) => mockCreateCustomProvider(...args),
}));
// Mock CustomModelDropdown since it has complex portal behavior
@@ -148,6 +152,8 @@ beforeEach(() => {
mockFetchGlobalSettings.mockResolvedValue({});
mockUpdateGlobalSettings.mockResolvedValue({});
mockCreateTask.mockResolvedValue({ id: "FN-TEST", description: "test task" });
mockFetchCustomProviders.mockResolvedValue({ providers: [] });
mockCreateCustomProvider.mockResolvedValue({ provider: {} });
mockLoginProvider.mockResolvedValue({ url: "https://auth.example.com/login" });
mockLogoutProvider.mockResolvedValue({ success: true });
mockSaveApiKey.mockResolvedValue({ success: true });
@@ -4024,3 +4030,20 @@ describe("ModelOnboardingModal progressive disclosure", () => {
});
});
});
describe("ModelOnboardingModal custom provider", () => {
it("shows add custom provider action in advanced provider settings", async () => {
render(
<ModelOnboardingModal
isOpen
onClose={() => {}}
onComplete={() => {}}
addToast={() => {}}
/>,
);
const advancedSummary = await screen.findByText("Advanced provider settings");
fireEvent.click(advancedSummary);
expect(await screen.findByRole("button", { name: "Add custom provider" })).toBeInTheDocument();
});
});

View File

@@ -15,6 +15,10 @@ const mockLoginProvider = vi.fn();
const mockLogoutProvider = vi.fn();
const mockSaveApiKey = vi.fn();
const mockFetchModels = vi.fn();
const mockFetchCustomProviders = vi.fn();
const mockCreateCustomProvider = vi.fn();
const mockUpdateCustomProvider = vi.fn();
const mockDeleteCustomProvider = vi.fn();
const mockTestNtfyNotification = vi.fn();
const mockTestNotification = vi.fn();
const mockFetchBackups = vi.fn();
@@ -57,6 +61,10 @@ vi.mock("../../api", () => ({
logoutProvider: (...args: unknown[]) => mockLogoutProvider(...args),
saveApiKey: (...args: unknown[]) => mockSaveApiKey(...args),
fetchModels: (...args: unknown[]) => mockFetchModels(...args),
fetchCustomProviders: (...args: unknown[]) => mockFetchCustomProviders(...args),
createCustomProvider: (...args: unknown[]) => mockCreateCustomProvider(...args),
updateCustomProvider: (...args: unknown[]) => mockUpdateCustomProvider(...args),
deleteCustomProvider: (...args: unknown[]) => mockDeleteCustomProvider(...args),
testNtfyNotification: (...args: unknown[]) => mockTestNtfyNotification(...args),
testNotification: (...args: unknown[]) => mockTestNotification(...args),
fetchBackups: (...args: unknown[]) => mockFetchBackups(...args),
@@ -195,6 +203,10 @@ describe("SettingsModal", () => {
mockFetchSettingsByScope.mockResolvedValue({ global: defaultSettings, project: {} });
mockFetchAuthStatus.mockResolvedValue({ providers: [] });
mockFetchModels.mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] });
mockFetchCustomProviders.mockResolvedValue({ providers: [] });
mockCreateCustomProvider.mockResolvedValue({ provider: {} });
mockUpdateCustomProvider.mockResolvedValue({ provider: {} });
mockDeleteCustomProvider.mockResolvedValue(undefined);
mockSaveApiKey.mockResolvedValue(undefined);
mockTestNotification.mockResolvedValue({ success: true });
mockFetchBackups.mockResolvedValue({ backups: [], totalSize: 0 });
@@ -2194,3 +2206,4 @@ describe("SettingsModal", () => {
});
});
});

View File

@@ -32,6 +32,10 @@ vi.mock("../../api", () => ({
updateGlobalSettings: vi.fn(),
fetchAuthStatus: (...args: unknown[]) => mockFetchAuthStatus(...args),
fetchModels: (...args: unknown[]) => mockFetchModels(...args),
fetchCustomProviders: vi.fn(() => Promise.resolve({ providers: [] })),
createCustomProvider: vi.fn(() => Promise.resolve({ provider: {} })),
updateCustomProvider: vi.fn(() => Promise.resolve({ provider: {} })),
deleteCustomProvider: vi.fn(() => Promise.resolve(undefined)),
fetchBackups: (...args: unknown[]) => mockFetchBackups(...args),
fetchMemoryFiles: (...args: unknown[]) => mockFetchMemoryFiles(...args),
fetchMemoryFile: (...args: unknown[]) => mockFetchMemoryFile(...args),

View File

@@ -50,6 +50,10 @@ vi.mock("../../api", () => ({
saveApiKey: vi.fn(() => Promise.resolve({ success: true })),
clearApiKey: vi.fn(() => Promise.resolve({ success: true })),
fetchModels: vi.fn(() => Promise.resolve({ models: [], favoriteProviders: [], favoriteModels: [] })),
fetchCustomProviders: vi.fn(() => Promise.resolve({ providers: [] })),
createCustomProvider: vi.fn(() => Promise.resolve({ provider: {} })),
updateCustomProvider: vi.fn(() => Promise.resolve({ provider: {} })),
deleteCustomProvider: vi.fn(() => Promise.resolve(undefined)),
testNtfyNotification: vi.fn(() => Promise.resolve({ success: true })),
testNotification: vi.fn(() => Promise.resolve({ success: true })),
fetchBackups: vi.fn(() => Promise.resolve({ count: 0, totalSize: 0, backups: [] })),

View File

@@ -18,6 +18,10 @@ export function getFusionAuthPath(home = process.env.HOME || process.env.USERPRO
return path.join(getFusionAgentDir(home), "auth.json");
}
export function getFusionModelsPath(home = process.env.HOME || process.env.USERPROFILE || homedir()): string {
return path.join(getFusionAgentDir(home), "models.json");
}
export function getAuthFileCandidates(
cwd = process.cwd(),
home = process.env.HOME || process.env.USERPROFILE || homedir(),

View File

@@ -110,6 +110,7 @@ import { registerAgentSkillsRoutes } from "./routes/register-agent-skills-routes
import { registerPluginsAutomationRoutes } from "./routes/register-plugins-automation.js";
import { registerProxyRoutes } from "./routes/register-proxy-routes.js";
import { registerModelRoutes } from "./routes/register-model-routes.js";
import { registerCustomProviderRoutes } from "./routes/register-custom-provider-routes.js";
import { registerUsageRoutes } from "./routes/register-usage-routes.js";
import { registerAuthRoutes } from "./routes/register-auth-routes.js";
import { registerRuntimeProviderRoutes } from "./routes/register-runtime-provider-routes.js";
@@ -1456,6 +1457,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
// Models
registerModelRoutes(routeContext);
registerCustomProviderRoutes(routeContext);
// ---------- Auth routes ----------
registerAuthRoutes(routeContext);

View File

@@ -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: {} });
});
});

View 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);
}
});
};