Adds a per-lane thinking-level selector to every fallback model picker across global, project, and workflow Settings surfaces, wiring them into the existing null-as-delete save paths. - Add inline thinking-level dropdowns to the global Fallback Model, workflow-declared planning/validator fallback lanes, and the project-scoped Title Summarizer fallback picker via CustomModelDropdown's `showThinkingLevel`. - Route `planningFallbackThinkingLevel`/`validatorFallbackThinkingLevel` through workflow settings PATCH and `titleSummarizerFallbackThinkingLevel` through project settings save-split, both with null-as-delete semantics on reset. - Move Title Summarizer fallback out of the workflow-declared model pairs into a dedicated project-scoped lane in ProjectModelsSection so its thinking companion isn't tied to workflow settings. - Update WorkflowSettingsPanel to surface the new fallback thinking companion keys. - Extend/adjust tests (settings-save-split, settings-sections, SettingsModal.models-auth, WorkflowSettingsPanel, core settings-migration) to cover the new selectors and save routing. - Document the fallback thinking-level runtime behavior in docs/settings-reference.md and docs/dashboard-guide.md. - Add a minor changeset describing the new fallback thinking-level selectors. Files changed: .changeset/fn-7795-fallback-thinking-selectors.md | 7 + docs/dashboard-guide.md | 4 +- docs/settings-reference.md | 10 +- packages/core/src/__tests__/settings-migration.test.ts | 14 +- packages/dashboard/app/__tests__/settings-save-split.test.ts | 35 +++++ packages/dashboard/app/__tests__/settings-sections.test.tsx | 163 ++++++++++++++++++++- packages/dashboard/app/components/WorkflowSettingsPanel.tsx | 6 +- packages/dashboard/app/components/__tests__/SettingsModal.models-auth.test.tsx | 11 +- packages/dashboard/app/components/__tests__/WorkflowSettingsPanel.test.tsx | 38 +++++ packages/dashboard/app/components/settings/save-split.ts | 9 +- packages/dashboard/app/components/settings/sections/GlobalModelsSection.tsx | 8 +- packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx | 58 ++++++-- 12 files changed, 328 insertions(+), 35 deletions(-) Fusion-Task-Id: FN-7795 Fusion-Task-Lineage: ec990d47-defe-4717-993a-56988afe8d7d Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
298 lines
21 KiB
TypeScript
298 lines
21 KiB
TypeScript
import type { ReactNode } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { THINKING_LEVELS } from "@fusion/core";
|
|
import type { Settings, ThinkingLevel } from "@fusion/core";
|
|
import type { ModelInfo } from "../../../api";
|
|
import type { ToastType } from "../../../hooks/useToast";
|
|
import { ModelPricingSection } from "./ModelPricingSection";
|
|
import { CustomModelDropdown } from "../../CustomModelDropdown";
|
|
import type { SectionBaseProps, ModelLane } from "./context";
|
|
import { LoadingSpinner } from "../../LoadingSpinner";
|
|
function toCommaSeparatedInput(values?: string[]): string {
|
|
return values?.join(", ") ?? "";
|
|
}
|
|
function fromCommaSeparatedInput(value: string): string[] {
|
|
return value.split(",").map((item) => item.trim()).filter((item) => item.length > 0);
|
|
}
|
|
export interface GlobalModelsSectionProps extends SectionBaseProps {
|
|
scopeBanner: ReactNode;
|
|
availableModels: ModelInfo[];
|
|
modelsLoading: boolean;
|
|
/** Global model lanes (i.e. MODEL_LANES without the `default` lane). */
|
|
globalModelLanes: ModelLane[];
|
|
getLaneThinkingValue: (lane: ModelLane) => string;
|
|
updateLaneThinkingValue: (lane: ModelLane, level: string) => void;
|
|
resetLaneThinkingValue: (lane: ModelLane) => void;
|
|
favoriteProviders: string[];
|
|
favoriteModels: string[];
|
|
onToggleFavorite: (provider: string) => void;
|
|
onToggleModelFavorite: (modelId: string) => void;
|
|
addToast: (message: string, type?: ToastType) => void;
|
|
projectId?: string;
|
|
}
|
|
export function GlobalModelsSection({ scopeBanner, form, setForm, availableModels, modelsLoading, globalModelLanes, getLaneThinkingValue, updateLaneThinkingValue, resetLaneThinkingValue, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, addToast, projectId, }: GlobalModelsSectionProps) {
|
|
const { t } = useTranslation("app");
|
|
const selectedValue = form.defaultProvider && form.defaultModelId
|
|
? `${form.defaultProvider}/${form.defaultModelId}`
|
|
: "";
|
|
return (<>
|
|
{scopeBanner}
|
|
|
|
{/* --- Default Model --- */}
|
|
<h4 className="settings-section-heading">{t("settings.globalModels.defaultModel", "Default Model")}</h4>
|
|
{modelsLoading ? (<div className="settings-empty-state"><LoadingSpinner label={t("settings.models.loadingModels", "Loading available models…")} /></div>) : availableModels.length === 0 ? (<div className="settings-empty-state settings-muted">
|
|
{t("settings.models.noModels", "No models available. Configure authentication first.")}
|
|
</div>) : (<>
|
|
<div className="form-group">
|
|
<label htmlFor="defaultModel">{t("settings.globalModels.defaultModel", "Default Model")}</label>
|
|
<CustomModelDropdown id="defaultModel" label="Default Model" models={availableModels} value={selectedValue} onChange={(val) => {
|
|
if (!val) {
|
|
setForm((f) => ({ ...f, defaultProvider: undefined, defaultModelId: undefined }));
|
|
}
|
|
else {
|
|
const slashIdx = val.indexOf("/");
|
|
setForm((f) => ({
|
|
...f,
|
|
defaultProvider: val.slice(0, slashIdx),
|
|
defaultModelId: val.slice(slashIdx + 1),
|
|
}));
|
|
}
|
|
}} placeholder={t("settings.globalModels.useDefault", "Use default")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite}/>
|
|
<small>{t("settings.globalModels.defaultAIModelUsedForTaskExecutionWhen", "Default AI model used for task execution when no per-task override is set. "Use default" lets the engine choose automatically. No default \u2014 unset.")}</small>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label htmlFor="fallbackModel">{t("settings.globalModels.fallbackModel", "Fallback Model")}</label>
|
|
{/* FNXC:Settings-ThinkingLevel 2026-07-10-12:00: Global fallback model selection owns its own thinking-level companion (`fallbackThinkingLevel`). Clearing the fallback picker must clear the companion value so null-as-delete reset parity matches the per-lane model pickers. */}
|
|
<CustomModelDropdown id="fallbackModel" label="Fallback Model" models={availableModels} value={form.fallbackProvider && form.fallbackModelId ? `${form.fallbackProvider}/${form.fallbackModelId}` : ""} onChange={(val) => {
|
|
if (!val) {
|
|
setForm((f) => ({ ...f, fallbackProvider: undefined, fallbackModelId: undefined, fallbackThinkingLevel: undefined }));
|
|
}
|
|
else {
|
|
const slashIdx = val.indexOf("/");
|
|
setForm((f) => ({
|
|
...f,
|
|
fallbackProvider: val.slice(0, slashIdx),
|
|
fallbackModelId: val.slice(slashIdx + 1),
|
|
}));
|
|
}
|
|
}} placeholder={t("settings.globalModels.noFallback", "No fallback")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} showThinkingLevel={(() => {
|
|
const selectedModel = availableModels.find((m) => m.provider === form.fallbackProvider && m.id === form.fallbackModelId);
|
|
return selectedModel ? Boolean(selectedModel.reasoning) : true;
|
|
})()} thinkingLevel={form.fallbackThinkingLevel || ""} onThinkingLevelChange={(level) => setForm((f) => ({ ...f, fallbackThinkingLevel: (level as ThinkingLevel) || undefined }))} defaultThinkingLevel={form.defaultThinkingLevel}/>
|
|
<small>{t("settings.globalModels.usedAutomaticallyIfThePrimaryDefaultModelHits", "Used automatically if the primary default model hits a retryable provider error like rate limiting or overload. No default \u2014 unset.")}</small>
|
|
</div>
|
|
</>)}
|
|
{(() => {
|
|
const selectedModel = availableModels.find((m) => m.provider === form.defaultProvider && m.id === form.defaultModelId);
|
|
if (selectedModel && !selectedModel.reasoning)
|
|
return null;
|
|
return (<div className="form-group">
|
|
{/* FNXC:Settings-ThinkingLevel 2026-06-19-14:55: This global selector renders the canonical THINKING_LEVELS list so newly added `xhigh` stays available anywhere the default reasoning effort is configured. */}
|
|
<label htmlFor="defaultThinkingLevel">{t("settings.globalModels.thinkingEffort", "Thinking Effort")}</label>
|
|
<select id="defaultThinkingLevel" value={form.defaultThinkingLevel || ""} onChange={(e) => {
|
|
const val = e.target.value;
|
|
setForm((f) => ({ ...f, defaultThinkingLevel: (val as ThinkingLevel) || undefined }));
|
|
}}>
|
|
<option value="">{t("settings.globalModels.default", "Default")}</option>
|
|
{THINKING_LEVELS.map((level) => (<option key={level} value={level}>
|
|
{level.charAt(0).toUpperCase() + level.slice(1)}
|
|
</option>))}
|
|
</select>
|
|
<small>{t("settings.globalModels.controlsHowMuchReasoningEffortTheAIModel", "Controls how much reasoning effort the AI model uses. Higher levels produce better results but cost more. No default \u2014 unset (model's own default effort applies).")}</small>
|
|
</div>);
|
|
})()}
|
|
|
|
{availableModels.length > 0 && (<>
|
|
<h4 className="settings-section-heading settings-section-heading--spaced">{t("settings.globalModels.modelLanes", "Model Lanes")}</h4>
|
|
<p className="settings-description">{t("settings.globalModels.globalBaselineModelsForEachAIRoleProject", " Global baseline models for each AI role. Project settings can override these per-project. ")}</p>
|
|
{globalModelLanes.map((lane) => {
|
|
const provider = form[lane.globalProviderKey as keyof Settings] as string | undefined;
|
|
const model = form[lane.globalModelKey as keyof Settings] as string | undefined;
|
|
const value = provider && model ? `${provider}/${model}` : "";
|
|
const thinkingValue = getLaneThinkingValue(lane);
|
|
return (<div className="form-group" key={`global-${lane.laneId}`}>
|
|
<label htmlFor={`global-${lane.laneId}-model`}>{lane.label}</label>
|
|
<CustomModelDropdown id={`global-${lane.laneId}-model`} label={lane.label} models={availableModels} value={value} onChange={(selected) => {
|
|
if (!selected) {
|
|
setForm((f) => ({
|
|
...f,
|
|
[lane.globalProviderKey]: undefined,
|
|
[lane.globalModelKey]: undefined,
|
|
}));
|
|
resetLaneThinkingValue(lane);
|
|
return;
|
|
}
|
|
const slashIdx = selected.indexOf("/");
|
|
setForm((f) => ({
|
|
...f,
|
|
[lane.globalProviderKey]: selected.slice(0, slashIdx),
|
|
[lane.globalModelKey]: selected.slice(slashIdx + 1),
|
|
}));
|
|
}} placeholder={t("settings.globalModels.useDefault", "Use default")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} showThinkingLevel={Boolean(lane.globalThinkingKey)} thinkingLevel={thinkingValue} onThinkingLevelChange={(level) => updateLaneThinkingValue(lane, level)} defaultThinkingLevel={form.defaultThinkingLevel}/>
|
|
<small>{lane.helperText}</small>
|
|
</div>);
|
|
})}
|
|
</>)}
|
|
|
|
<ModelPricingSection form={form} setForm={setForm} addToast={addToast} projectId={projectId}/>
|
|
|
|
{/* --- Startup Model Sync --- */}
|
|
<h4 className="settings-section-heading settings-section-heading--spaced">{t("settings.globalModels.startupModelSync", "Startup Model Sync")}</h4>
|
|
<div className="form-group">
|
|
<label htmlFor="openrouterModelSync" className="checkbox-label">
|
|
<input id="openrouterModelSync" type="checkbox" checked={form.openrouterModelSync !== false} onChange={(e) => setForm((f) => ({ ...f, openrouterModelSync: e.target.checked }))}/>{t("settings.globalModels.syncOpenRouterModelListAtStartup", " Sync OpenRouter model list at startup ")}</label>
|
|
<small>{t("settings.globalModels.whenEnabledStartupFetchesTheLatestAvailableModels", " When enabled, startup fetches the latest available models from the OpenRouter API so model pickers always include the newest catalog. Default: enabled. ")}</small>
|
|
</div>
|
|
<div className="form-group">
|
|
<label htmlFor="opencodeGoModelSync" className="checkbox-label">
|
|
<input id="opencodeGoModelSync" type="checkbox" checked={form.opencodeGoModelSync !== false} onChange={(e) => setForm((f) => ({ ...f, opencodeGoModelSync: e.target.checked }))}/>{t("settings.globalModels.syncOpencodeGoModelListAtStartup", " Sync opencode-go model list at startup ")}</label>
|
|
<small>{t("settings.globalModels.whenEnabledStartupRefreshesModelsThroughTheLocal", " When enabled, startup refreshes models through the local ")}<code>opencode models opencode --refresh</code>{t("settings.globalModels.flowAndPublishesThemUnderTheOpencodeGo", " flow and publishes them under the opencode-go provider in model pickers. Default: enabled. ")}</small>
|
|
</div>
|
|
<details>
|
|
<summary>{t("settings.globalModels.openRouterAdvanced", "OpenRouter advanced")}</summary>
|
|
<div className="form-group">
|
|
<label htmlFor="openrouterAppAttributionReferer">{t("settings.globalModels.openRouterHTTPReferer", "OpenRouter HTTP-Referer")}</label>
|
|
<input id="openrouterAppAttributionReferer" className="input" placeholder={t("settings.globalModels.httpsRunfusionAi", "https://runfusion.ai")} value={form.openrouterAppAttribution?.referer ?? ""} onChange={(e) => setForm((f) => ({
|
|
...f,
|
|
openrouterAppAttribution: {
|
|
...(f.openrouterAppAttribution || {}),
|
|
referer: e.target.value,
|
|
},
|
|
}))}/>
|
|
<small>{t("settings.globalModels.leaveEmptyToOmitThisHeaderDefaultHttps", "Leave empty to omit this header. No default — unset (Fusion falls back to https://runfusion.ai when unset).")}</small>
|
|
</div>
|
|
<div className="form-group">
|
|
<label htmlFor="openrouterAppAttributionTitle">{t("settings.globalModels.openRouterXTitle", "OpenRouter X-Title")}</label>
|
|
<input id="openrouterAppAttributionTitle" className="input" placeholder={t("settings.globalModels.fusion", "Fusion")} value={form.openrouterAppAttribution?.title ?? ""} onChange={(e) => setForm((f) => ({
|
|
...f,
|
|
openrouterAppAttribution: {
|
|
...(f.openrouterAppAttribution || {}),
|
|
title: e.target.value,
|
|
},
|
|
}))}/>
|
|
<small>{t("settings.globalModels.leaveEmptyToOmitThisHeaderDefaultFusion", "Leave empty to omit this header. No default — unset (Fusion falls back to the title \"Fusion\" when unset).")}</small>
|
|
</div>
|
|
<div className="form-group">
|
|
<label htmlFor="openrouterModelFiltersSupportedParameters">{t("settings.globalModels.openRouterSupportedParametersFilter", "OpenRouter supported_parameters filter")}</label>
|
|
<input id="openrouterModelFiltersSupportedParameters" className="input" placeholder={t("settings.globalModels.toolsStructuredOutputs", "tools, structured_outputs")} value={toCommaSeparatedInput(form.openrouterModelFilters?.supported_parameters)} onChange={(e) => {
|
|
const parsed = fromCommaSeparatedInput(e.target.value);
|
|
setForm((f) => ({
|
|
...f,
|
|
openrouterModelFilters: {
|
|
...(f.openrouterModelFilters || {}),
|
|
supported_parameters: parsed.length > 0 ? parsed : undefined,
|
|
},
|
|
}));
|
|
}}/>
|
|
<small>{t("settings.globalModels.commaSeparatedValuesSentToOpenRouterModelSync", "Comma-separated values sent to OpenRouter model sync. No default \u2014 unset (unfiltered).")}</small>
|
|
</div>
|
|
<div className="form-group">
|
|
<label htmlFor="openrouterModelFiltersOutputModalities">{t("settings.globalModels.openRouterOutputModalitiesFilter", "OpenRouter output_modalities filter")}</label>
|
|
<input id="openrouterModelFiltersOutputModalities" className="input" placeholder={t("settings.globalModels.text", "text")} value={toCommaSeparatedInput(form.openrouterModelFilters?.output_modalities)} onChange={(e) => {
|
|
const parsed = fromCommaSeparatedInput(e.target.value);
|
|
setForm((f) => ({
|
|
...f,
|
|
openrouterModelFilters: {
|
|
...(f.openrouterModelFilters || {}),
|
|
output_modalities: parsed.length > 0 ? parsed : undefined,
|
|
},
|
|
}));
|
|
}}/>
|
|
<small>{t("settings.globalModels.commaSeparatedValuesSentToOpenRouterModelSyncOutputModalities", "Comma-separated values sent to OpenRouter model sync. No default \u2014 unset (unfiltered).")}</small>
|
|
</div>
|
|
<div className="form-group">
|
|
<label htmlFor="openrouterProviderPreferencesOrder">{t("settings.globalModels.openRouterRoutingOrder", "OpenRouter routing order")}</label>
|
|
<input id="openrouterProviderPreferencesOrder" className="input" placeholder={t("settings.globalModels.openaiAnthropic", "openai, anthropic")} value={toCommaSeparatedInput(form.openrouterProviderPreferences?.order)} onChange={(e) => {
|
|
const parsed = fromCommaSeparatedInput(e.target.value);
|
|
setForm((f) => ({
|
|
...f,
|
|
openrouterProviderPreferences: {
|
|
...(f.openrouterProviderPreferences || {}),
|
|
order: parsed.length > 0 ? parsed : undefined,
|
|
},
|
|
}));
|
|
}}/>
|
|
<small>{t("settings.globalModels.openRouterRoutingOrderHint", "No default \u2014 unset (OpenRouter's own default routing order applies).")}</small>
|
|
</div>
|
|
<div className="form-group">
|
|
<label htmlFor="openrouterProviderPreferencesIgnore">{t("settings.globalModels.openRouterRoutingIgnore", "OpenRouter routing ignore")}</label>
|
|
<input id="openrouterProviderPreferencesIgnore" className="input" placeholder={t("settings.globalModels.providerName", "provider-name")} value={toCommaSeparatedInput(form.openrouterProviderPreferences?.ignore)} onChange={(e) => {
|
|
const parsed = fromCommaSeparatedInput(e.target.value);
|
|
setForm((f) => ({
|
|
...f,
|
|
openrouterProviderPreferences: {
|
|
...(f.openrouterProviderPreferences || {}),
|
|
ignore: parsed.length > 0 ? parsed : undefined,
|
|
},
|
|
}));
|
|
}}/>
|
|
<small>{t("settings.globalModels.openRouterRoutingIgnoreHint", "No default \u2014 unset (no providers ignored).")}</small>
|
|
</div>
|
|
<div className="form-group">
|
|
<label htmlFor="openrouterProviderPreferencesOnly">{t("settings.globalModels.openRouterRoutingOnly", "OpenRouter routing only")}</label>
|
|
<input id="openrouterProviderPreferencesOnly" className="input" placeholder={t("settings.globalModels.providerName", "provider-name")} value={toCommaSeparatedInput(form.openrouterProviderPreferences?.only)} onChange={(e) => {
|
|
const parsed = fromCommaSeparatedInput(e.target.value);
|
|
setForm((f) => ({
|
|
...f,
|
|
openrouterProviderPreferences: {
|
|
...(f.openrouterProviderPreferences || {}),
|
|
only: parsed.length > 0 ? parsed : undefined,
|
|
},
|
|
}));
|
|
}}/>
|
|
<small>{t("settings.globalModels.openRouterRoutingOnlyHint", "No default \u2014 unset (no provider restriction).")}</small>
|
|
</div>
|
|
<div className="form-group">
|
|
<label htmlFor="openrouterProviderPreferencesAllowFallbacks">{t("settings.globalModels.openRouterAllowFallbacks", "OpenRouter allow fallbacks")}</label>
|
|
<select id="openrouterProviderPreferencesAllowFallbacks" className="select" value={form.openrouterProviderPreferences?.allow_fallbacks === undefined ? "default" : form.openrouterProviderPreferences.allow_fallbacks ? "allow" : "deny"} onChange={(e) => {
|
|
const value = e.target.value;
|
|
setForm((f) => ({
|
|
...f,
|
|
openrouterProviderPreferences: {
|
|
...(f.openrouterProviderPreferences || {}),
|
|
allow_fallbacks: value === "default" ? undefined : value === "allow",
|
|
},
|
|
}));
|
|
}}>
|
|
<option value="default">{t("settings.globalModels.default2", "default")}</option>
|
|
<option value="allow">{t("settings.globalModels.allow", "allow")}</option>
|
|
<option value="deny">{t("settings.globalModels.deny", "deny")}</option>
|
|
</select>
|
|
<small>{t("settings.globalModels.openRouterAllowFallbacksHint", "No default \u2014 unset (OpenRouter's own default fallback behavior applies).")}</small>
|
|
</div>
|
|
<div className="form-group">
|
|
<label htmlFor="openrouterProviderPreferencesSort">{t("settings.globalModels.openRouterRoutingSort", "OpenRouter routing sort")}</label>
|
|
<select id="openrouterProviderPreferencesSort" className="select" value={form.openrouterProviderPreferences?.sort ?? "default"} onChange={(e) => {
|
|
const value = e.target.value;
|
|
setForm((f) => ({
|
|
...f,
|
|
openrouterProviderPreferences: {
|
|
...(f.openrouterProviderPreferences || {}),
|
|
sort: value === "default" ? undefined : value as "price" | "throughput" | "latency",
|
|
},
|
|
}));
|
|
}}>
|
|
<option value="default">{t("settings.globalModels.default2", "default")}</option>
|
|
<option value="price">{t("settings.globalModels.price", "price")}</option>
|
|
<option value="throughput">{t("settings.globalModels.throughput", "throughput")}</option>
|
|
<option value="latency">{t("settings.globalModels.latency", "latency")}</option>
|
|
</select>
|
|
<small>{t("settings.globalModels.openRouterRoutingSortHint", "No default \u2014 unset (OpenRouter's own default sort applies).")}</small>
|
|
</div>
|
|
<div className="form-group">
|
|
<label htmlFor="openrouterProviderPreferencesRequireParameters" className="checkbox-label">
|
|
<input id="openrouterProviderPreferencesRequireParameters" type="checkbox" checked={form.openrouterProviderPreferences?.require_parameters === true} onChange={(e) => setForm((f) => ({
|
|
...f,
|
|
openrouterProviderPreferences: {
|
|
...(f.openrouterProviderPreferences || {}),
|
|
require_parameters: e.target.checked,
|
|
},
|
|
}))}/>{t("settings.globalModels.requireParameters", " Require parameters ")}</label>
|
|
<small>{t("settings.globalModels.requireParametersHint", "Default: disabled.")}</small>
|
|
</div>
|
|
</details>
|
|
</>);
|
|
}
|
|
export default GlobalModelsSection;
|