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:
5
.changeset/custom-openai-anthropic-providers.md
Normal file
5
.changeset/custom-openai-anthropic-providers.md
Normal 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.
|
||||
@@ -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[];
|
||||
|
||||
45
packages/dashboard/app/components/CustomProviderForm.css
Normal file
45
packages/dashboard/app/components/CustomProviderForm.css
Normal 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;
|
||||
}
|
||||
}
|
||||
203
packages/dashboard/app/components/CustomProviderForm.tsx
Normal file
203
packages/dashboard/app/components/CustomProviderForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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: [] })),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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