Files
fusion/packages/dashboard/app/components/CustomProviderForm.tsx
Fusion 19cdf7f448 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
2026-04-29 20:15:05 -07:00

204 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}