Files
fusion/packages/dashboard/app/components/CustomProviderForm.tsx
XiaoHuo888-hue 41c23adf15 feat: add OrcaRouter as a named model provider (#3502)
## Summary

Adds [OrcaRouter](https://www.orcarouter.ai) as a first-class named
model provider, mirroring the existing OpenRouter integration.
OrcaRouter is an OpenAI-compatible gateway —
`https://api.orcarouter.ai/v1` — that routes to models from many
providers (Anthropic, OpenAI, DeepSeek, Google, Grok, Qwen, Zhipu, and
more) through a single API key. It also runs gateway-level, zero-trust
security for AI agents on the same endpoint — screening every
prompt/response and governing every tool call on a default-deny basis,
with no application code changes.

What this PR does:

- **Catalog sync**: `syncStartupModels` now fetches the OrcaRouter
`/v1/models` catalog at startup (gated by a new `orcarouterModelSync`
global setting, default enabled) and registers an `openai-completions`
provider at `https://api.orcarouter.ai/v1`. The key resolves from the
`ORCAROUTER_API_KEY` environment variable.
- **Auth + onboarding**: OrcaRouter appears in the API-key auth catalog,
onboarding setup copy + key hints, and quick-start provider list (key
format `sk-orca-...`, dashboard link to `https://www.orcarouter.ai`).
- **Icons**: New OrcaRouter brand mark (`OrcaRouterIcon`) with a
dedicated color token, wired into `ProviderIcon` and
`inferProviderIconKey` so `orcarouter/...` model ids render the brand
mark everywhere.
- **Settings**: Startup-sync toggle under **Settings → Global Models**,
settings search entry, and save-split allowlists.
- **Docs + i18n**: `docs/settings-reference.md` / `docs/storage.md` rows
and `en` + secondary-locale catalog keys.
- **Changeset**: `.changeset/add-orcarouter-provider.md` (minor).

Disclosure: I'm an engineer on the OrcaRouter team.

## Test plan

- `pnpm --filter @runfusion/fusion exec vitest run
src/commands/__tests__/startup-model-sync.test.ts` — 18 passed
- `pnpm --filter @fusion/core exec vitest run
src/__tests__/settings-parity.test.ts` — 80 passed
- `pnpm --filter @fusion/dashboard exec vitest run
app/components/__tests__/ProviderIcon.test.tsx` — 115 passed
- `pnpm --filter @fusion/dashboard exec vitest run
app/components/__tests__/AuthenticationSection.test.tsx
app/components/__tests__/ModelOnboardingModal.test.tsx` — 222 passed
- `tsc --noEmit` in `@fusion/cli`, `@fusion/core`, `@fusion/dashboard`,
`@fusion/engine` — all clean
- `pnpm lint` on changed files — clean
- `pnpm check:changesets` — clean
- **L3 live test**: exercised the production `syncStartupModels` path
against the live OrcaRouter API — catalog sync registered 209 models and
a real chat completion returned **HTTP 200** (`finish_reason: "stop"`).

Note: `pnpm i18n:status` reports pre-existing Jira key-parity gaps in
the `app.json` catalogs on a clean checkout (unrelated to this change);
the `common.json` catalogs touched here are in parity.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
  - Added OrcaRouter as a built-in OpenAI-compatible provider.
- Added API-key setup, validation, authentication, onboarding guidance,
provider branding, and model catalog visibility.
- Added automatic startup synchronization of OrcaRouter models, enabled
by default.
  - Added a global setting to enable or disable model synchronization.
- **Documentation**
  - Documented the new synchronization setting and its configuration.
- **Localization**
  - Added OrcaRouter setup and usage text across supported languages.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Signed-off-by: XiaoHuo888-hue <jinhao.song@myflashcloud.com>
Co-authored-by: XiaoHuo888-hue <jinhao.song@myflashcloud.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-08-21 19:30:08 -07:00

286 lines
13 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 { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Loader2, Search } from "lucide-react";
import type { CustomProviderConfig, CustomProviderModelInput } from "../api";
import { probeProviderModels } from "../api";
import "./CustomProviderForm.css";
// Reserved built-in IDs (including hidden/deprecated aliases) to prevent custom-provider collisions.
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", "orcarouter", "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: "" };
}
export function CustomProviderForm({ initialConfig, onSave, onCancel, saving = false, error }: Props) {
const { t } = useTranslation("app");
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 [detecting, setDetecting] = useState(false);
const [detectError, setDetectError] = 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)));
}
// Detect Models is available for all API types that expose a /models endpoint:
// - openai-completions / openai-responses → openai-compatible
// - anthropic-messages → anthropic-compatible
// - google-generative-ai → google-generative-ai
const probeApiType = api === "anthropic-messages"
? "anthropic-compatible"
: api === "google-generative-ai"
? "google-generative-ai"
: "openai-compatible";
const handleDetectModels = useCallback(async () => {
const trimmedBaseUrl = baseUrl.trim();
if (!trimmedBaseUrl) {
setDetectError(t("providers.detectError.urlRequired", "Base URL is required to detect models."));
return;
}
setDetecting(true);
setDetectError(null);
try {
const result = await probeProviderModels({
baseUrl: trimmedBaseUrl,
apiKey: apiKey.trim() || undefined,
apiType: probeApiType,
});
if (result.models.length === 0) {
setDetectError(t("providers.detectError.noModels", "No models found. The provider may require an API key."));
return;
}
// Merge discovered models, avoiding duplicates by ID
const existingIds = new Set(models.map((m) => m.id.trim()));
const newModels = result.models
.filter((m) => !existingIds.has(m.id.trim()))
.map((m) => ({
id: m.id,
name: m.name || m.id,
contextWindow: m.contextWindow,
maxTokens: m.maxTokens,
}));
if (newModels.length > 0) {
// Replace empty default rows with discovered models
setModels((prev) => {
const nonEmpty = prev.filter((m) => m.id.trim().length > 0);
return [...nonEmpty, ...newModels];
});
} else {
setDetectError(t("providers.detectError.allDuplicate", "All discovered models are already in the list."));
}
} catch (err) {
setDetectError(
err instanceof Error ? err.message : t("providers.detectError.failed", "Failed to detect models"),
);
} finally {
setDetecting(false);
}
}, [baseUrl, apiKey, probeApiType, models, t]);
function validate(): string | null {
if (!id.trim()) return t("providers.validation.idRequired", "Provider ID is required.");
if (!PROVIDER_ID_PATTERN.test(id.trim())) return t("providers.validation.idKebabCase", "Provider ID must be kebab-case.");
if (!editing && BUILT_IN_PROVIDER_IDS.has(id.trim())) return t("providers.validation.idConflict", "Provider ID conflicts with a built-in provider.");
if (!baseUrl.trim()) return t("providers.validation.urlRequired", "Base URL is required.");
try {
const parsed = new URL(baseUrl.trim());
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return t("providers.validation.urlProtocol", "Base URL must use http or https.");
}
} catch {
return t("providers.validation.urlValid", "Base URL must be a valid URL.");
}
if (!API_TYPES.includes(api)) return t("providers.validation.apiTypeRequired", "API type is required.");
if (models.length === 0) return t("providers.validation.modelRequired", "At least one model is required.");
if (models.some((model) => !model.id?.trim())) return t("providers.validation.modelId", "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,
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">{t("providers.fields.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">{t("providers.fields.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">{t("providers.fields.baseUrl", "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">{t("providers.fields.apiType", "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">{t("providers.fields.apiKey", "API Key")}</label>
<input id="custom-provider-api-key" className="input" placeholder={t("providers.placeholders.apiKey", "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>{t("providers.fields.models", "Models")}</label>
<div className="custom-provider-form__models">
{models.map((model, index) => (
/*
FNXC:CustomProviders 2026-08-19-15:13:
There is no reasoning capability control because custom models are presumed thinking-capable.
Pi derives selector options and execution behavior from their shared server registration.
*/
<div key={`${index}-model`} className="custom-provider-form__model-row">
<input
className="input"
aria-label={`${t("providers.fields.modelId", "Model ID")} ${index + 1}`}
placeholder={t("providers.fields.modelId", "Model ID")}
value={model.id}
onChange={(e) => updateModel(index, { id: e.target.value })}
disabled={saving}
/>
<input
className="input"
aria-label={`${t("providers.fields.modelNameLabel", "Model name")} ${index + 1}`}
placeholder={t("providers.fields.modelName", "Display name")}
value={model.name ?? ""}
onChange={(e) => updateModel(index, { name: e.target.value })}
disabled={saving}
/>
<input
className="input"
aria-label={`${t("providers.fields.contextWindow", "Context window")} ${index + 1}`}
placeholder={t("providers.fields.contextWindow", "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={`${t("providers.fields.maxTokens", "Max tokens")} ${index + 1}`}
placeholder={t("providers.fields.maxTokens", "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={t("providers.actions.removeModel", "Remove model", { count: index + 1 })}
>
×
</button>
</div>
))}
</div>
<div className="custom-provider-form__model-actions" style={{ display: "flex", gap: "8px", alignItems: "center" }}>
<button type="button" className="btn btn-sm" onClick={() => setModels((prev) => [...prev, emptyModel()])} disabled={saving}>
{t("providers.actions.addModel", "+ Add model")}
</button>
<button
type="button"
className="btn btn-sm"
onClick={() => void handleDetectModels()}
disabled={saving || detecting || !baseUrl.trim()}
title={t("providers.actions.detectModelsTitle", "Call the provider's /models endpoint to discover available models")}
>
{detecting ? (
<>
<Loader2 className="spin" size={14} /> {t("providers.actions.detecting", "Detecting…")}
</>
) : (
<>
<Search size={14} /> {t("providers.actions.detectModels", "Detect Models")}
</>
)}
</button>
</div>
{detectError ? <div className="form-error" style={{ marginTop: "4px" }}>{detectError}</div> : null}
</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}>{t("actions.cancel", "Cancel")}</button> : null}
<button type="submit" className="btn btn-primary" disabled={saving}>{saving ? t("providers.actions.saving", "Saving...") : t("providers.actions.save", "Save Provider")}</button>
</div>
</form>
);
}