FN-8666: add credential instance pickers

Add credential-instance selection across dashboard model configuration surfaces.

- Expose provider credential instances in custom model dropdowns and task, workflow, and settings forms.
- Persist per-role credential-instance overrides through dashboard APIs and effective model resolution.
- Document credential-instance precedence and cover dropdown/settings behavior, including task state synchronization.

Files changed:
 .changeset/fn-8666-credential-instance-picker.md   |   7 ++
 docs/settings-reference.md                         |   2 +
 docs/workflow-steps.md                             |   2 +-
 packages/dashboard/app/api.ts                      |   1 +
 packages/dashboard/app/api/models-usage.ts         |  27 ++++-
 packages/dashboard/app/api/tasks.ts                |   8 ++
 .../app/components/CustomModelDropdown.css         |  12 ++-
 .../app/components/CustomModelDropdown.tsx         |  71 ++++++++++++-
 .../dashboard/app/components/InlineCreateCard.tsx  |  23 +++-
 packages/dashboard/app/components/ListView.tsx     |  29 +++++-
 .../app/components/ModelSelectionModal.tsx         |  25 ++++-
 .../dashboard/app/components/ModelSelectorTab.tsx  |  29 +++++-
 packages/dashboard/app/components/NewTaskModal.tsx |  23 +++-
 .../dashboard/app/components/QuickEntryBox.tsx     |  28 +++++
 .../dashboard/app/components/SettingsModal.tsx     |   2 +
 .../dashboard/app/components/TaskDetailModal.tsx   |  32 +++++-
 packages/dashboard/app/components/TaskForm.tsx     |  18 ++++
 .../app/components/WorkflowNodeEditor.tsx          |  13 ++-
 .../app/components/WorkflowSettingsPanel.tsx       |  38 ++++++-
 ...ustomModelDropdown.credential-instance.test.tsx | 111 ++++++++++++++++++++
 .../__tests__/WorkflowSettingsPanel.test.tsx       |   9 +-
 .../app/components/effective-model-resolution.ts   |   8 +-
 .../settings/sections/GlobalModelsSection.tsx      |  31 +++++-
 .../settings/sections/ProjectModelsSection.tsx     | 116 ++++++++++++++++-----
 .../ProjectModelsSection.chatDefault.test.tsx      |  33 +++++-
 packages/dashboard/app/hooks/useFavorites.ts       |   6 +-
 packages/dashboard/app/hooks/useModelsCache.ts     |   5 +-
 .../src/routes/register-task-workflow-routes.ts    |  17 ++-
 28 files changed, 655 insertions(+), 71 deletions(-)

Fusion-Task-Id: FN-8666

Fusion-Task-Lineage: 6c3711b3-68b8-47a0-ac36-4b64846adf39

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-01 04:03:26 -07:00
parent 4f09758dc8
commit 0e8f6769ba
28 changed files with 655 additions and 71 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Let operators select configured credential instances from model pickers.
category: feature
dev: The picker displays a control only for providers with multiple configured instances.

View File

@@ -1808,6 +1808,8 @@ All three lanes (planning / executor / reviewer) follow the same precedence:
5. Project `defaultProviderOverride` / `defaultModelIdOverride`
6. Global `defaultProvider` / `defaultModelId` → automatic resolution
A selected credential instance is part of the winning provider/model selection. The model picker shows a **Credential instance** control only when `/api/models` advertises two or more configured instances for that provider. Selecting **Default** removes the instance override entirely; providers with zero or one instance keep the unchanged picker UI.
## Mock provider (test mode)
Set `defaultProvider: "mock"` at any tier in that hierarchy (or the per-task lane override) to force planning, executor, reviewer/validator, mission validation, merger, and heartbeat sessions onto the deterministic zero-network mock runtime.

View File

@@ -562,7 +562,7 @@ A prompt-mode gate node can set its own model with:
- `modelId`
- `thinkingLevel` (`"off" | "minimal" | "low" | "medium" | "high" | "xhigh"`)
If both model fields are set, node execution uses that provider/model pair; otherwise it falls back to default model selection. `thinkingLevel` is stored as `config.thinkingLevel` and can be set or cleared independently from the model pair. Runtime reasoning-effort precedence is **node/step `thinkingLevel` → task `thinkingLevel` → workflow lane thinking override (`executionThinkingLevel`, `planningThinkingLevel`, or `validatorThinkingLevel`) → global lane thinking override → project default thinking override → global `defaultThinkingLevel`**. The lane settings accept `off`, `minimal`, `low`, `medium`, `high`, or `xhigh`; unset means inherit. This applies to prompt/gate custom nodes, the `execute` and `step-execute` seams, triage/planning, and `step-review` reviewer sessions. Dashboard node summaries show unpinned provider/model state as **Default model**; the inspector's inline thinking selector shows the resolved project default (e.g. "Default (off)") when no node-level thinking value is pinned.
If a node also sets `config.credentialInstanceId`, execution resolves that configured instance for the node's provider/model pair; clearing it uses the provider default credential. If both model fields are set, node execution uses that provider/model pair; otherwise it falls back to default model selection. `thinkingLevel` is stored as `config.thinkingLevel` and can be set or cleared independently from the model pair. Runtime reasoning-effort precedence is **node/step `thinkingLevel` → task `thinkingLevel` → workflow lane thinking override (`executionThinkingLevel`, `planningThinkingLevel`, or `validatorThinkingLevel`) → global lane thinking override → project default thinking override → global `defaultThinkingLevel`**. The lane settings accept `off`, `minimal`, `low`, `medium`, `high`, or `xhigh`; unset means inherit. This applies to prompt/gate custom nodes, the `execute` and `step-execute` seams, triage/planning, and `step-review` reviewer sessions. Dashboard node summaries show unpinned provider/model state as **Default model**; the inspector's inline thinking selector shows the resolved project default (e.g. "Default (off)") when no node-level thinking value is pinned.
## Default-On Behavior for New Tasks

View File

@@ -6,6 +6,7 @@
*/
export * from "./api/legacy";
export * from "./api/provider-status";
export * from "./api/models-usage";
export * from "./api/chat";
export * from "./api-node";
export * from "./api/report";

View File

@@ -14,22 +14,45 @@ export interface ModelInfo {
name: string;
reasoning: boolean;
contextWindow: number;
/** Provider-wide public instance metadata, attached by fetchModels for picker consumers. */
credentialInstances?: ProviderCredentialInstanceSummary[];
}
/** Response from the models endpoint */
/** Public metadata for a configured provider credential; it never contains credential material. */
export interface ProviderCredentialInstanceSummary {
id: string;
isDefault: boolean;
unavailableModelIds?: string[];
}
export interface ModelsResponse {
models: ModelInfo[];
favoriteProviders: string[];
favoriteModels: string[];
defaultProvider?: string;
/** Configured credential instances keyed by provider, omitted by older servers. */
providerInstances?: Record<string, { instances: ProviderCredentialInstanceSummary[] }>;
defaultModelId?: string;
resolvedPlanningProvider?: string;
resolvedPlanningModelId?: string;
}
/** Fetch available AI models from the model registry along with favoriteProviders */
export function fetchModels(): Promise<ModelsResponse> {
return api<ModelsResponse>("/models");
export async function fetchModels(): Promise<ModelsResponse> {
const response = await api<ModelsResponse>("/models");
/*
FNXC:ModelDropdown 2026-08-01-09:49:
Every existing model picker already receives the catalog returned by this client. Attach public provider-instance summaries to those rows so callers that only retain `models` still receive the optional picker availability without a parallel endpoint or a second dropdown implementation.
*/
return {
...response,
models: (response.models ?? []).map((model) => ({
...model,
credentialInstances: response.providerInstances?.[model.provider]?.instances,
})),
};
}
// --- Usage API ---

View File

@@ -323,12 +323,16 @@ export function updateTask(
status?: null;
modelProvider?: string | null;
modelId?: string | null;
credentialInstanceId?: string | null;
validatorModelProvider?: string | null;
validatorModelId?: string | null;
validatorCredentialInstanceId?: string | null;
planningModelProvider?: string | null;
planningModelId?: string | null;
planningCredentialInstanceId?: string | null;
mergerModelProvider?: string | null;
mergerModelId?: string | null;
mergerCredentialInstanceId?: string | null;
thinkingLevel?: string | null;
validatorThinkingLevel?: string | null;
planningThinkingLevel?: string | null;
@@ -382,6 +386,8 @@ export function batchUpdateTaskModels(
nodeId?: string | null,
thinkingLevel?: string | null,
projectId?: string,
credentialInstanceId?: string | null,
validatorCredentialInstanceId?: string | null,
): Promise<{ updated: Task[]; count: number }> {
return api<{ updated: Task[]; count: number }>(withProjectId("/tasks/batch-update-models", projectId), {
method: "POST",
@@ -395,6 +401,8 @@ export function batchUpdateTaskModels(
planningModelId,
nodeId,
...(thinkingLevel !== undefined ? { thinkingLevel } : {}),
...(credentialInstanceId !== undefined ? { credentialInstanceId } : {}),
...(validatorCredentialInstanceId !== undefined ? { validatorCredentialInstanceId } : {}),
}),
});
}

View File

@@ -137,7 +137,8 @@ The model combobox is portaled outside Agent Settings but remains part of its ru
border-bottom: var(--btn-border-width) solid var(--border);
}
.model-combobox-thinking {
.model-combobox-thinking,
.model-combobox-instance {
position: relative;
display: flex;
align-items: center;
@@ -148,7 +149,8 @@ The model combobox is portaled outside Agent Settings but remains part of its ru
border-bottom: var(--btn-border-width) solid var(--border);
}
.model-combobox-thinking-label {
.model-combobox-thinking-label,
.model-combobox-instance-label {
flex: 1;
min-width: 0;
color: var(--text-muted);
@@ -156,7 +158,8 @@ The model combobox is portaled outside Agent Settings but remains part of its ru
font-weight: 600;
}
.model-combobox-thinking-select {
.model-combobox-thinking-select,
.model-combobox-instance-select {
flex: 1;
min-width: 0;
}
@@ -210,7 +213,8 @@ The Thinking Level <select> renders inside the createPortal dropdown (.model-com
font-weight: 600;
}
.model-combobox-thinking::after {
.model-combobox-thinking::after,
.model-combobox-instance::after {
content: "";
position: absolute;
right: calc(var(--space-md) + var(--space-sm));

View File

@@ -3,7 +3,7 @@ import { useState, useEffect, useCallback, useMemo, useRef, useId } from "react"
import { THINKING_LEVELS } from "@fusion/core";
import { useTranslation } from "react-i18next";
import { createPortal } from "react-dom";
import type { ModelInfo } from "../api";
import type { ModelInfo, ProviderCredentialInstanceSummary } from "../api";
import { filterModels } from "../utils/modelFilter";
import { ProviderIcon } from "./ProviderIcon";
@@ -39,6 +39,12 @@ export interface CustomModelDropdownProps {
defaultThinkingLevel?: string;
/** Explicitly render the optional inline thinking-level selector even without a change callback. */
showThinkingLevel?: boolean;
/** Optional selected credential instance; empty or absent means provider default. */
credentialInstanceId?: string;
/** Called when the inline credential-instance selector changes; empty means clear the persisted override. */
onCredentialInstanceChange?: (instanceId: string) => void;
/** Available credential instances keyed by provider, as advertised by /api/models. */
credentialInstances?: Record<string, { instances: ProviderCredentialInstanceSummary[] }>;
}
interface DropdownPosition {
@@ -101,6 +107,9 @@ export function CustomModelDropdown({
onThinkingLevelChange,
defaultThinkingLevel,
showThinkingLevel,
credentialInstanceId,
onCredentialInstanceChange,
credentialInstances,
}: CustomModelDropdownProps) {
const { t } = useTranslation("app");
const placeholder = placeholderProp ?? t("model.selectPlaceholder", "Select a model…");
@@ -113,6 +122,7 @@ export function CustomModelDropdown({
const [portalRoot, setPortalRoot] = useState<HTMLElement | null>(null);
const [collapsedProviders, setCollapsedProviders] = useState<Set<string>>(loadCollapsedProviders);
const generatedThinkingId = useId();
const generatedInstanceId = useId();
const containerRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
@@ -220,6 +230,38 @@ export function CustomModelDropdown({
return slashIdx === -1 ? null : value.slice(0, slashIdx);
}, [hasNoChangeOption, noChangeValue, value]);
const instanceOptions = useMemo(() => {
if (!currentProvider) return [];
const seen = new Set<string>();
const available = credentialInstances?.[currentProvider]?.instances
?? models.find((model) => model.provider === currentProvider)?.credentialInstances
?? [];
const deduplicated = available.filter((instance) => {
if (!instance.id || seen.has(instance.id)) return false;
seen.add(instance.id);
return true;
});
/*
FNXC:ModelDropdown 2026-08-01-09:13:
A stale persisted id is retained only after the availability threshold is met. It must not make a single-instance provider sprout this optional control.
*/
if (deduplicated.length >= 2 && credentialInstanceId && !seen.has(credentialInstanceId)) {
deduplicated.push({ id: credentialInstanceId, isDefault: false });
}
return deduplicated;
}, [credentialInstanceId, credentialInstances, currentProvider, models]);
const shouldShowCredentialInstance = instanceOptions.length >= 2;
const normalizedCredentialInstanceId = credentialInstanceId ?? "";
const credentialInstanceSelectId = id ? `${id}-credential-instance` : `${generatedInstanceId}-credential-instance`;
/*
FNXC:ModelDropdown 2026-08-01-09:13:
Credential-instance selection is visible exactly when a selected provider advertises at least two distinct instances. Rendering nothing otherwise keeps existing and older-server picker menus structurally unchanged; ownership callbacks determine persistence, never visibility.
FNXC:ModelDropdown 2026-08-01-09:13:
A stale selected instance remains an explicit option rather than being cleared during render. Only an operator change may remove a persisted override, preventing unrelated saves from silently changing runtime credentials.
*/
const specialOptions = useMemo(() => {
const options: Array<{ type: "default" | "no-change"; value: string; label: string }> = [];
if (hasNoChangeOption) {
@@ -637,6 +679,28 @@ export function CustomModelDropdown({
{t("models.count", { count: filteredModels.length, defaultValue_one: "{{count}} model", defaultValue_other: "{{count}} models" })}
</div>
{shouldShowCredentialInstance && (
<div className="model-combobox-instance" onMouseDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}>
<label className="model-combobox-instance-label" htmlFor={credentialInstanceSelectId}>
{t("models.labels.credentialInstance", "Credential instance")}
</label>
<select
id={credentialInstanceSelectId}
className="thinking-level-select model-combobox-instance-select"
data-testid="custom-model-dropdown-credential-instance"
value={normalizedCredentialInstanceId}
onChange={(e) => onCredentialInstanceChange?.(e.target.value)}
disabled={disabled || !onCredentialInstanceChange}
aria-label={t("models.labels.credentialInstance", "Credential instance")}
>
<option value="">{t("models.credentialInstanceDefault", "Default")}</option>
{instanceOptions.map((instance) => (
<option key={instance.id} value={instance.id}>{instance.id}</option>
))}
</select>
</div>
)}
{shouldShowThinking && (
<div className="model-combobox-thinking" onMouseDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}>
<label className="model-combobox-thinking-label" htmlFor={thinkingSelectId}>
@@ -834,6 +898,11 @@ export function CustomModelDropdown({
{thinkingBadgeLabel}
</span>
)}
{shouldShowCredentialInstance && normalizedCredentialInstanceId && (
<span className="model-badge model-badge-custom model-combobox-thinking-badge" data-testid="custom-model-dropdown-credential-instance-badge">
{normalizedCredentialInstanceId}
</span>
)}
<span className="model-combobox-trigger-arrow">▼</span>
</button>
</div>

View File

@@ -118,12 +118,16 @@ export function InlineCreateCard({
const [settings, setSettings] = useState<Settings | null>(null);
const [selectedPresetId, setSelectedPresetId] = useState<string | undefined>(undefined);
const [executorProvider, setExecutorProvider] = useState<string | undefined>(undefined);
const [credentialInstanceId, setCredentialInstanceId] = useState<string | undefined>(undefined);
const [executorModelId, setExecutorModelId] = useState<string | undefined>(undefined);
const [validatorProvider, setValidatorProvider] = useState<string | undefined>(undefined);
const [validatorCredentialInstanceId, setValidatorCredentialInstanceId] = useState<string | undefined>(undefined);
const [validatorModelId, setValidatorModelId] = useState<string | undefined>(undefined);
const [planningProvider, setPlanningProvider] = useState<string | undefined>(undefined);
const [planningCredentialInstanceId, setPlanningCredentialInstanceId] = useState<string | undefined>(undefined);
const [planningModelId, setPlanningModelId] = useState<string | undefined>(undefined);
const [mergerProvider, setMergerProvider] = useState<string | undefined>(undefined);
const [mergerCredentialInstanceId, setMergerCredentialInstanceId] = useState<string | undefined>(undefined);
const [mergerModelId, setMergerModelId] = useState<string | undefined>(undefined);
/* FNXC:Settings-ThinkingLevel 2026-07-09-00:00: quick-create board card carries the same per-task thinking-level override as the full New Task modal; "" means "use default". */
const [thinkingLevel, setThinkingLevel] = useState<string>("");
@@ -500,12 +504,16 @@ export function InlineCreateCard({
...(selectedAgentId ? { assignedAgentId: selectedAgentId } : {}),
modelPresetId: selectedPresetId,
modelProvider: hasExecutorOverride ? executorProvider : undefined,
credentialInstanceId: hasExecutorOverride ? credentialInstanceId : undefined,
modelId: hasExecutorOverride ? executorModelId : undefined,
validatorModelProvider: hasValidatorOverride ? validatorProvider : undefined,
validatorCredentialInstanceId: hasValidatorOverride ? validatorCredentialInstanceId : undefined,
validatorModelId: hasValidatorOverride ? validatorModelId : undefined,
planningModelProvider: hasPlanningOverride ? planningProvider : undefined,
planningCredentialInstanceId: hasPlanningOverride ? planningCredentialInstanceId : undefined,
planningModelId: hasPlanningOverride ? planningModelId : undefined,
mergerModelProvider: hasMergerOverride ? mergerProvider : undefined,
mergerCredentialInstanceId: hasMergerOverride ? mergerCredentialInstanceId : undefined,
mergerModelId: hasMergerOverride ? mergerModelId : undefined,
validatorThinkingLevel: validatorThinkingLevel !== "" ? validatorThinkingLevel as ThinkingLevel : undefined,
planningThinkingLevel: planningThinkingLevel !== "" ? planningThinkingLevel as ThinkingLevel : undefined,
@@ -532,7 +540,7 @@ export function InlineCreateCard({
}
await submitTask(input);
}, [description, submitting, selectedWorkflowId, dependencies, selectedAgentId, selectedPresetId, hasExecutorOverride, executorProvider, executorModelId, hasValidatorOverride, validatorProvider, validatorModelId, hasPlanningOverride, planningProvider, planningModelId, hasMergerOverride, mergerProvider, mergerModelId, thinkingLevel, validatorThinkingLevel, planningThinkingLevel, mergerThinkingLevel, optionalSteps.length, enabledOptionalStepIds, priority, effectiveNodeId, projectId, addToast, submitTask]);
}, [description, submitting, selectedWorkflowId, dependencies, selectedAgentId, selectedPresetId, hasExecutorOverride, executorProvider, credentialInstanceId, executorModelId, hasValidatorOverride, validatorProvider, validatorCredentialInstanceId, validatorModelId, hasPlanningOverride, planningProvider, planningCredentialInstanceId, planningModelId, hasMergerOverride, mergerProvider, mergerCredentialInstanceId, mergerModelId, thinkingLevel, validatorThinkingLevel, planningThinkingLevel, mergerThinkingLevel, optionalSteps.length, enabledOptionalStepIds, priority, effectiveNodeId, projectId, addToast, submitTask]);
const handleDuplicateProceed = useCallback(async () => {
const matches = duplicateMatches;
@@ -667,18 +675,21 @@ export function InlineCreateCard({
const next = parseModelSelection(value);
setExecutorProvider(next.provider);
setExecutorModelId(next.modelId);
setCredentialInstanceId(undefined);
}, []);
const handleValidatorChange = useCallback((value: string) => {
const next = parseModelSelection(value);
setValidatorProvider(next.provider);
setValidatorModelId(next.modelId);
setValidatorCredentialInstanceId(undefined);
}, []);
const handlePlanningModelChange = useCallback((value: string) => {
const next = parseModelSelection(value);
setPlanningProvider(next.provider);
setPlanningModelId(next.modelId);
setPlanningCredentialInstanceId(undefined);
}, []);
const handleThinkingLevelChange = useCallback((value: string) => {
@@ -1239,7 +1250,15 @@ export function InlineCreateCard({
onExecutorChange={handleExecutorChange}
onValidatorChange={handleValidatorChange}
onPlanningChange={handlePlanningModelChange}
onMergerChange={(value) => { const next = parseModelSelection(value); setMergerProvider(next.provider); setMergerModelId(next.modelId); }}
onMergerChange={(value) => { const next = parseModelSelection(value); setMergerProvider(next.provider); setMergerModelId(next.modelId); setMergerCredentialInstanceId(undefined); }}
credentialInstanceId={credentialInstanceId}
onCredentialInstanceChange={(value) => setCredentialInstanceId(value || undefined)}
validatorCredentialInstanceId={validatorCredentialInstanceId}
onValidatorCredentialInstanceChange={(value) => setValidatorCredentialInstanceId(value || undefined)}
planningCredentialInstanceId={planningCredentialInstanceId}
onPlanningCredentialInstanceChange={(value) => setPlanningCredentialInstanceId(value || undefined)}
mergerCredentialInstanceId={mergerCredentialInstanceId}
onMergerCredentialInstanceChange={(value) => setMergerCredentialInstanceId(value || undefined)}
mergerThinkingLevel={mergerThinkingLevel}
onMergerThinkingLevelChange={setMergerThinkingLevel}
validatorThinkingLevel={validatorThinkingLevel}

View File

@@ -606,7 +606,9 @@ export function ListView({
// Bulk edit state and handlers (declared before clearSelection so every clear path resets pending lane edits)
const [executorModel, setExecutorModel] = useState<string>("__no_change__");
const [credentialInstanceId, setCredentialInstanceId] = useState<string>("__no_change__");
const [validatorModel, setValidatorModel] = useState<string>("__no_change__");
const [validatorCredentialInstanceId, setValidatorCredentialInstanceId] = useState<string>("__no_change__");
const [bulkThinkingLevel, setBulkThinkingLevel] = useState<string>("__no_change__");
const [nodeOverride, setNodeOverride] = useState<string>("__no_change__");
@@ -615,7 +617,9 @@ export function ListView({
if (prev) {
setSelectedTaskIds(new Set());
setExecutorModel("__no_change__");
setCredentialInstanceId("__no_change__");
setValidatorModel("__no_change__");
setValidatorCredentialInstanceId("__no_change__");
setBulkThinkingLevel("__no_change__");
setNodeOverride("__no_change__");
}
@@ -640,7 +644,9 @@ export function ListView({
const clearSelection = useCallback(() => {
setSelectedTaskIds(new Set());
setExecutorModel("__no_change__");
setCredentialInstanceId("__no_change__");
setValidatorModel("__no_change__");
setValidatorCredentialInstanceId("__no_change__");
setBulkThinkingLevel("__no_change__");
setNodeOverride("__no_change__");
}, []);
@@ -1779,6 +1785,8 @@ export function ListView({
validatorModelId?: string | null;
nodeId?: string | null;
thinkingLevel?: ThinkingLevel | null;
credentialInstanceId?: string | null;
validatorCredentialInstanceId?: string | null;
} = { taskIds };
if (executorModel !== "__no_change__") {
@@ -1786,11 +1794,13 @@ export function ListView({
// "Use default" - clear override
payload.modelProvider = null;
payload.modelId = null;
payload.credentialInstanceId = null;
} else {
const slashIdx = executorModel.indexOf("/");
if (slashIdx !== -1) {
payload.modelProvider = executorModel.slice(0, slashIdx);
payload.modelId = executorModel.slice(slashIdx + 1);
payload.credentialInstanceId = null;
}
}
}
@@ -1800,15 +1810,20 @@ export function ListView({
// "Use default" - clear override
payload.validatorModelProvider = null;
payload.validatorModelId = null;
payload.validatorCredentialInstanceId = null;
} else {
const slashIdx = validatorModel.indexOf("/");
if (slashIdx !== -1) {
payload.validatorModelProvider = validatorModel.slice(0, slashIdx);
payload.validatorModelId = validatorModel.slice(slashIdx + 1);
payload.validatorCredentialInstanceId = null;
}
}
}
if (credentialInstanceId !== "__no_change__") payload.credentialInstanceId = credentialInstanceId || null;
if (validatorCredentialInstanceId !== "__no_change__") payload.validatorCredentialInstanceId = validatorCredentialInstanceId || null;
if (nodeOverride !== "__no_change__") {
if (nodeOverride === "") {
payload.nodeId = null;
@@ -1840,6 +1855,8 @@ export function ListView({
payload.nodeId,
payload.thinkingLevel,
projectId,
payload.credentialInstanceId,
payload.validatorCredentialInstanceId,
);
if (onTasksUpdated) {
@@ -1851,7 +1868,9 @@ export function ListView({
// Reset state
clearSelection();
setExecutorModel("__no_change__");
setCredentialInstanceId("__no_change__");
setValidatorModel("__no_change__");
setValidatorCredentialInstanceId("__no_change__");
setBulkThinkingLevel("__no_change__");
setNodeOverride("__no_change__");
} catch (err) {
@@ -1859,7 +1878,7 @@ export function ListView({
} finally {
setIsApplying(false);
}
}, [addToast, bulkThinkingLevel, clearSelection, executorModel, isTaskArchivedColumn, nodeOverride, onTasksUpdated, projectId, selectedTaskIds, tasks, validatorModel]);
}, [addToast, bulkThinkingLevel, clearSelection, credentialInstanceId, executorModel, isTaskArchivedColumn, nodeOverride, onTasksUpdated, projectId, selectedTaskIds, tasks, validatorCredentialInstanceId, validatorModel]);
const closeContextMenu = useCallback(() => {
setContextMenuState(null);
@@ -2776,7 +2795,9 @@ export function ListView({
<CustomModelDropdown
models={availableModels}
value={executorModel}
onChange={setExecutorModel}
onChange={(value) => { setCredentialInstanceId("__no_change__"); setExecutorModel(value); }}
credentialInstanceId={credentialInstanceId === "__no_change__" ? undefined : credentialInstanceId}
onCredentialInstanceChange={setCredentialInstanceId}
label={t("listView.executorModel", "Executor Model")}
noChangeValue="__no_change__"
noChangeLabel={t("listView.noChange", "No change")}
@@ -2790,7 +2811,9 @@ export function ListView({
<CustomModelDropdown
models={availableModels}
value={validatorModel}
onChange={setValidatorModel}
onChange={(value) => { setValidatorCredentialInstanceId("__no_change__"); setValidatorModel(value); }}
credentialInstanceId={validatorCredentialInstanceId === "__no_change__" ? undefined : validatorCredentialInstanceId}
onCredentialInstanceChange={setValidatorCredentialInstanceId}
label={t("listView.reviewerModel", "Reviewer Model")}
noChangeValue="__no_change__"
noChangeLabel={t("listView.noChange", "No change")}

View File

@@ -22,6 +22,15 @@ interface ModelSelectionModalProps {
onValidatorChange: (value: string) => void;
onPlanningChange?: (value: string) => void;
onMergerChange?: (value: string) => void;
/** Optional persisted instance overrides for task model lanes. */
credentialInstanceId?: string;
validatorCredentialInstanceId?: string;
planningCredentialInstanceId?: string;
mergerCredentialInstanceId?: string;
onCredentialInstanceChange?: (value: string) => void;
onValidatorCredentialInstanceChange?: (value: string) => void;
onPlanningCredentialInstanceChange?: (value: string) => void;
onMergerCredentialInstanceChange?: (value: string) => void;
mergerThinkingLevel?: string;
onMergerThinkingLevelChange?: (value: string) => void;
validatorThinkingLevel?: string;
@@ -77,6 +86,14 @@ export function ModelSelectionModal({
onValidatorChange,
onPlanningChange,
onMergerChange,
credentialInstanceId = "",
validatorCredentialInstanceId = "",
planningCredentialInstanceId = "",
mergerCredentialInstanceId = "",
onCredentialInstanceChange,
onValidatorCredentialInstanceChange,
onPlanningCredentialInstanceChange,
onMergerCredentialInstanceChange,
mergerThinkingLevel = "",
onMergerThinkingLevelChange,
validatorThinkingLevel = "",
@@ -288,6 +305,8 @@ export function ModelSelectionModal({
onToggleModelFavorite={onToggleModelFavorite}
thinkingLevel={planningThinkingLevel}
onThinkingLevelChange={onPlanningThinkingLevelChange}
credentialInstanceId={planningCredentialInstanceId}
onCredentialInstanceChange={onPlanningCredentialInstanceChange}
defaultThinkingLevel={defaultThinkingLevel ?? "off"}
/>
</div>
@@ -318,6 +337,8 @@ export function ModelSelectionModal({
onToggleModelFavorite={onToggleModelFavorite}
thinkingLevel={thinkingLevel}
onThinkingLevelChange={onThinkingLevelChange}
credentialInstanceId={credentialInstanceId}
onCredentialInstanceChange={onCredentialInstanceChange}
defaultThinkingLevel={defaultThinkingLevel ?? "off"}
/>
</div>
@@ -326,7 +347,7 @@ export function ModelSelectionModal({
{onMergerChange ? <div className="task-detail-section"><div className="inline-create-model-row">
<label htmlFor="model-selection-merger" className="inline-create-model-label">{t("tasks.mergerModel", "Merger Model")}</label>
<span className={`model-badge ${hasMergerOverride ? "model-badge-custom" : "model-badge-default"}`}>{getModelBadgeLabel(models, mergerValue, t)}</span>
<CustomModelDropdown id="model-selection-merger" label={t("tasks.mergerModel", "Merger Model")} value={mergerValue} onChange={onMergerChange} models={models} placeholder={t("tasks.usingDefault", "Using default")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} thinkingLevel={mergerThinkingLevel} onThinkingLevelChange={onMergerThinkingLevelChange} defaultThinkingLevel={defaultThinkingLevel ?? "off"} />
<CustomModelDropdown id="model-selection-merger" label={t("tasks.mergerModel", "Merger Model")} value={mergerValue} onChange={onMergerChange} models={models} placeholder={t("tasks.usingDefault", "Using default")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} thinkingLevel={mergerThinkingLevel} onThinkingLevelChange={onMergerThinkingLevelChange} credentialInstanceId={mergerCredentialInstanceId} onCredentialInstanceChange={onMergerCredentialInstanceChange} defaultThinkingLevel={defaultThinkingLevel ?? "off"} />
</div></div> : null}
<div className="task-detail-section">
@@ -353,6 +374,8 @@ export function ModelSelectionModal({
onToggleModelFavorite={onToggleModelFavorite}
thinkingLevel={validatorThinkingLevel}
onThinkingLevelChange={onValidatorThinkingLevelChange}
credentialInstanceId={validatorCredentialInstanceId}
onCredentialInstanceChange={onValidatorCredentialInstanceChange}
defaultThinkingLevel={defaultThinkingLevel ?? "off"}
/>
</div>

View File

@@ -28,6 +28,7 @@ interface ModelSelectorTabProps {
interface ModelSelection {
provider?: string;
modelId?: string;
credentialInstanceId?: string;
}
function normalizeModelField(value: string | null | undefined): string | undefined {
@@ -38,6 +39,7 @@ function getExecutorSelection(task: Task | TaskDetail): ModelSelection {
return {
provider: normalizeModelField(task.modelProvider),
modelId: normalizeModelField(task.modelId),
credentialInstanceId: normalizeModelField(task.credentialInstanceId),
};
}
@@ -45,6 +47,7 @@ function getValidatorSelection(task: Task | TaskDetail): ModelSelection {
return {
provider: normalizeModelField(task.validatorModelProvider),
modelId: normalizeModelField(task.validatorModelId),
credentialInstanceId: normalizeModelField(task.validatorCredentialInstanceId),
};
}
@@ -52,6 +55,7 @@ function getPlanningSelection(task: Task | TaskDetail): ModelSelection {
return {
provider: normalizeModelField(task.planningModelProvider),
modelId: normalizeModelField(task.planningModelId),
credentialInstanceId: normalizeModelField(task.planningCredentialInstanceId),
};
}
@@ -59,6 +63,7 @@ function getMergerSelection(task: Task | TaskDetail): ModelSelection {
return {
provider: normalizeModelField(task.mergerModelProvider),
modelId: normalizeModelField(task.mergerModelId),
credentialInstanceId: normalizeModelField(task.mergerCredentialInstanceId),
};
}
@@ -109,7 +114,7 @@ function getDropdownValue(selection: ModelSelection): string {
}
function selectionsEqual(a: ModelSelection, b: ModelSelection): boolean {
return a.provider === b.provider && a.modelId === b.modelId;
return a.provider === b.provider && a.modelId === b.modelId && a.credentialInstanceId === b.credentialInstanceId;
}
function getSuccessToastMessage(
@@ -141,6 +146,7 @@ export function ModelSelectorTab({ task, addToast, onTaskUpdated, settings, proj
const { t } = useTranslation("app");
const {
availableModels,
providerInstances,
favoriteProviders,
favoriteModels,
toggleFavoriteProvider,
@@ -213,7 +219,7 @@ export function ModelSelectorTab({ task, addToast, onTaskUpdated, settings, proj
setSelectedMergerThinking(nextMergerThinking);
setSavedMergerThinking(nextMergerThinking);
setSavingTarget(null);
}, [task.id, task.modelProvider, task.modelId, task.validatorModelProvider, task.validatorModelId, task.planningModelProvider, task.planningModelId, task.mergerModelProvider, task.mergerModelId, task.thinkingLevel, task.validatorThinkingLevel, task.planningThinkingLevel, task.mergerThinkingLevel]);
}, [task.id, task.modelProvider, task.modelId, task.credentialInstanceId, task.validatorModelProvider, task.validatorModelId, task.validatorCredentialInstanceId, task.planningModelProvider, task.planningModelId, task.planningCredentialInstanceId, task.mergerModelProvider, task.mergerModelId, task.mergerCredentialInstanceId, task.thinkingLevel, task.validatorThinkingLevel, task.planningThinkingLevel, task.mergerThinkingLevel]);
const executorValue = useMemo(() => getDropdownValue(selectedExecutor), [selectedExecutor]);
const validatorValue = useMemo(() => getDropdownValue(selectedValidator), [selectedValidator]);
@@ -241,30 +247,38 @@ export function ModelSelectorTab({ task, addToast, onTaskUpdated, settings, proj
? {
modelProvider: nextSelection.provider ?? null,
modelId: nextSelection.modelId ?? null,
credentialInstanceId: nextSelection.credentialInstanceId ?? null,
}
: target === "validator"
? {
validatorModelProvider: nextSelection.provider ?? null,
validatorModelId: nextSelection.modelId ?? null,
validatorCredentialInstanceId: nextSelection.credentialInstanceId ?? null,
}
: target === "merger"
? {
mergerModelProvider: nextSelection.provider ?? null,
mergerModelId: nextSelection.modelId ?? null,
mergerCredentialInstanceId: nextSelection.credentialInstanceId ?? null,
}
: {
planningModelProvider: nextSelection.provider ?? null,
planningModelId: nextSelection.modelId ?? null,
planningCredentialInstanceId: nextSelection.credentialInstanceId ?? null,
})
: {
modelProvider: (target === "executor" ? nextSelection : savedExecutor).provider ?? null,
modelId: (target === "executor" ? nextSelection : savedExecutor).modelId ?? null,
credentialInstanceId: (target === "executor" ? nextSelection : savedExecutor).credentialInstanceId ?? null,
validatorModelProvider: (target === "validator" ? nextSelection : savedValidator).provider ?? null,
validatorModelId: (target === "validator" ? nextSelection : savedValidator).modelId ?? null,
validatorCredentialInstanceId: (target === "validator" ? nextSelection : savedValidator).credentialInstanceId ?? null,
planningModelProvider: (target === "planning" ? nextSelection : savedPlanning).provider ?? null,
planningModelId: (target === "planning" ? nextSelection : savedPlanning).modelId ?? null,
planningCredentialInstanceId: (target === "planning" ? nextSelection : savedPlanning).credentialInstanceId ?? null,
mergerModelProvider: (target === "merger" ? nextSelection : savedMerger).provider ?? null,
mergerModelId: (target === "merger" ? nextSelection : savedMerger).modelId ?? null,
mergerCredentialInstanceId: (target === "merger" ? nextSelection : savedMerger).credentialInstanceId ?? null,
};
/*
@@ -599,6 +613,9 @@ export function ModelSelectorTab({ task, addToast, onTaskUpdated, settings, proj
value={executorValue}
onChange={handleExecutorChange}
models={availableModels}
credentialInstances={providerInstances}
credentialInstanceId={selectedExecutor.credentialInstanceId}
onCredentialInstanceChange={(credentialInstanceId) => void saveSelection("executor", { ...selectedExecutor, credentialInstanceId: credentialInstanceId || undefined })}
disabled={isSaving}
placeholder={t("models.placeholders.selectExecutor", "Select executor model…")}
favoriteProviders={favoriteProviders}
@@ -632,6 +649,9 @@ export function ModelSelectorTab({ task, addToast, onTaskUpdated, settings, proj
value={validatorValue}
onChange={handleValidatorChange}
models={availableModels}
credentialInstances={providerInstances}
credentialInstanceId={selectedValidator.credentialInstanceId}
onCredentialInstanceChange={(credentialInstanceId) => void saveSelection("validator", { ...selectedValidator, credentialInstanceId: credentialInstanceId || undefined })}
disabled={isSaving}
placeholder={t("models.placeholders.selectReviewer", "Select reviewer model…")}
favoriteProviders={favoriteProviders}
@@ -665,6 +685,9 @@ export function ModelSelectorTab({ task, addToast, onTaskUpdated, settings, proj
value={planningValue}
onChange={handlePlanningChange}
models={availableModels}
credentialInstances={providerInstances}
credentialInstanceId={selectedPlanning.credentialInstanceId}
onCredentialInstanceChange={(credentialInstanceId) => void saveSelection("planning", { ...selectedPlanning, credentialInstanceId: credentialInstanceId || undefined })}
disabled={isSaving}
placeholder={t("models.placeholders.selectPlanning", "Select planning model…")}
favoriteProviders={favoriteProviders}
@@ -684,7 +707,7 @@ export function ModelSelectorTab({ task, addToast, onTaskUpdated, settings, proj
<div className="model-selector-current">
{mergerUsingDefault ? <span className="model-badge model-badge-default">{t("models.states.usingDefault", "Using default")}{effectiveMerger.provider && effectiveMerger.modelId ? ` (${effectiveMerger.provider}/${effectiveMerger.modelId})` : ""}</span> : <span className="model-badge model-badge-custom">{savedMerger.provider && <ProviderIcon provider={savedMerger.provider} size="sm" />}{savedMerger.provider}/{savedMerger.modelId}</span>}
</div>
<CustomModelDropdown id="mergerModel" label={t("tasks.mergerModel", "Merger Model")} value={mergerValue} onChange={handleMergerChange} models={availableModels} disabled={isSaving} placeholder={t("tasks.usingDefault", "Using default")} favoriteProviders={favoriteProviders} onToggleFavorite={handleToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={handleToggleModelFavorite} thinkingLevel={selectedMergerThinking ?? ""} onThinkingLevelChange={handleMergerThinkingChange} defaultThinkingLevel={settings?.mergerThinkingLevel ?? "off"} />
<CustomModelDropdown id="mergerModel" label={t("tasks.mergerModel", "Merger Model")} value={mergerValue} onChange={handleMergerChange} models={availableModels} credentialInstances={providerInstances} credentialInstanceId={selectedMerger.credentialInstanceId} onCredentialInstanceChange={(credentialInstanceId) => void saveSelection("merger", { ...selectedMerger, credentialInstanceId: credentialInstanceId || undefined })} disabled={isSaving} placeholder={t("tasks.usingDefault", "Using default")} favoriteProviders={favoriteProviders} onToggleFavorite={handleToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={handleToggleModelFavorite} thinkingLevel={selectedMergerThinking ?? ""} onThinkingLevelChange={handleMergerThinkingChange} defaultThinkingLevel={settings?.mergerThinkingLevel ?? "off"} />
<small>{t("models.descriptions.merger", "The AI model used to merge this task.")}</small>
</div>

View File

@@ -358,8 +358,11 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
const [isSubmitting, setIsSubmitting] = useState(false);
const [duplicateMatches, setDuplicateMatches] = useState<DuplicateMatch[] | null>(null);
const [executorModel, setExecutorModel] = useState("");
const [credentialInstanceId, setCredentialInstanceId] = useState<string | undefined>(undefined);
const [validatorModel, setValidatorModel] = useState("");
const [validatorCredentialInstanceId, setValidatorCredentialInstanceId] = useState<string | undefined>(undefined);
const [planningModel, setPlanningModel] = useState("");
const [planningCredentialInstanceId, setPlanningCredentialInstanceId] = useState<string | undefined>(undefined);
const [thinkingLevel, setThinkingLevel] = useState<string>("");
// FNXC:PlannerOversight 2026-07-04-00:00: Per-task override of the workflow-native plannerOversightLevel setting (FN-7508). "" means inherit from workflow.
const [plannerOversightLevel, setPlannerOversightLevel] = useState<string>("");
@@ -575,8 +578,11 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
setDescription("");
setDependencies([]);
setExecutorModel("");
setCredentialInstanceId(undefined);
setValidatorModel("");
setValidatorCredentialInstanceId(undefined);
setPlanningModel("");
setPlanningCredentialInstanceId(undefined);
setThinkingLevel("");
setPlannerOversightLevel("");
setSelectedPresetId("");
@@ -649,10 +655,13 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
modelPresetId: presetMode === "preset" ? selectedPresetId || undefined : undefined,
modelProvider: executorModel && executorSlashIdx !== -1 ? executorModel.slice(0, executorSlashIdx) : undefined,
modelId: executorModel && executorSlashIdx !== -1 ? executorModel.slice(executorSlashIdx + 1) : undefined,
...(credentialInstanceId ? { credentialInstanceId } : {}),
validatorModelProvider: validatorModel && validatorSlashIdx !== -1 ? validatorModel.slice(0, validatorSlashIdx) : undefined,
validatorModelId: validatorModel && validatorSlashIdx !== -1 ? validatorModel.slice(validatorSlashIdx + 1) : undefined,
...(validatorCredentialInstanceId ? { validatorCredentialInstanceId } : {}),
planningModelProvider: planningModel && planningSlashIdx !== -1 ? planningModel.slice(0, planningSlashIdx) : undefined,
planningModelId: planningModel && planningSlashIdx !== -1 ? planningModel.slice(planningSlashIdx + 1) : undefined,
...(planningCredentialInstanceId ? { planningCredentialInstanceId } : {}),
thinkingLevel: thinkingLevel !== "" ? thinkingLevel as "minimal" | "low" | "medium" | "high" | "xhigh" : undefined,
// FNXC:PlannerOversight 2026-07-04-00:00: omit when "Inherit from workflow" ("") is selected so the task falls back to the workflow's effective plannerOversightLevel.
...(plannerOversightLevel !== "" ? { plannerOversightLevel: plannerOversightLevel as "off" | "observe" | "steer" | "autonomous" } : {}),
@@ -700,7 +709,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
resetForm();
addToast(t("newTaskModal.taskCreated", "Created {{taskId}}", { taskId: task.id }), "success");
onClose();
}, [executorModel, validatorModel, planningModel, thinkingLevel, plannerOversightLevel, dependencies, selectedWorkflowId, shouldSubmitEnabledWorkflowSteps, enabledWorkflowSteps, selectedAgentId, presetMode, selectedPresetId, reviewLevel, autoMerge, priority, nodeId, executionMode, branchMode, isBranchNameRequired, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed, onCreateTask, pendingImages, resetForm, addToast, t, onClose, projectId]);
}, [executorModel, credentialInstanceId, validatorModel, validatorCredentialInstanceId, planningModel, planningCredentialInstanceId, thinkingLevel, plannerOversightLevel, dependencies, selectedWorkflowId, shouldSubmitEnabledWorkflowSteps, enabledWorkflowSteps, selectedAgentId, presetMode, selectedPresetId, reviewLevel, autoMerge, priority, nodeId, executionMode, branchMode, isBranchNameRequired, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed, onCreateTask, pendingImages, resetForm, addToast, t, onClose, projectId]);
const handleSubmit = useCallback(async () => {
const trimmedDesc = description.trim();
@@ -1033,9 +1042,13 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
dependencies={dependencies}
onDependenciesChange={setDependencies}
executorModel={executorModel}
onExecutorModelChange={handleExecutorModelChange}
onExecutorModelChange={(value, meta) => { setCredentialInstanceId(undefined); handleExecutorModelChange(value, meta); }}
credentialInstanceId={credentialInstanceId}
onCredentialInstanceIdChange={(instanceId) => setCredentialInstanceId(instanceId || undefined)}
validatorModel={validatorModel}
onValidatorModelChange={handleValidatorModelChange}
onValidatorModelChange={(value, meta) => { setValidatorCredentialInstanceId(undefined); handleValidatorModelChange(value, meta); }}
validatorCredentialInstanceId={validatorCredentialInstanceId}
onValidatorCredentialInstanceIdChange={(instanceId) => setValidatorCredentialInstanceId(instanceId || undefined)}
presetMode={presetMode}
onPresetModeChange={setPresetMode}
selectedPresetId={selectedPresetId}
@@ -1055,7 +1068,9 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
onPlanningMode={onPlanningMode}
onSubtaskBreakdown={onSubtaskBreakdown}
planningModel={planningModel}
onPlanningModelChange={setPlanningModel}
onPlanningModelChange={(value) => { setPlanningCredentialInstanceId(undefined); setPlanningModel(value); }}
planningCredentialInstanceId={planningCredentialInstanceId}
onPlanningCredentialInstanceIdChange={(instanceId) => setPlanningCredentialInstanceId(instanceId || undefined)}
thinkingLevel={thinkingLevel}
plannerOversightLevel={plannerOversightLevel}
onPlannerOversightLevelChange={setPlannerOversightLevel}

View File

@@ -199,12 +199,16 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
const [activeModelSubmenu, setActiveModelSubmenu] = useState<"plan" | "executor" | "validator" | "merger" | null>(null);
const [executorProvider, setExecutorProvider] = useState<string | undefined>(undefined);
const [executorModelId, setExecutorModelId] = useState<string | undefined>(undefined);
const [credentialInstanceId, setCredentialInstanceId] = useState<string | undefined>(undefined);
const [validatorProvider, setValidatorProvider] = useState<string | undefined>(undefined);
const [validatorModelId, setValidatorModelId] = useState<string | undefined>(undefined);
const [validatorCredentialInstanceId, setValidatorCredentialInstanceId] = useState<string | undefined>(undefined);
const [planningProvider, setPlanningProvider] = useState<string | undefined>(undefined);
const [planningModelId, setPlanningModelId] = useState<string | undefined>(undefined);
const [planningCredentialInstanceId, setPlanningCredentialInstanceId] = useState<string | undefined>(undefined);
const [mergerProvider, setMergerProvider] = useState<string | undefined>(undefined);
const [mergerModelId, setMergerModelId] = useState<string | undefined>(undefined);
const [mergerCredentialInstanceId, setMergerCredentialInstanceId] = useState<string | undefined>(undefined);
/* FNXC:Settings-ThinkingLevel 2026-07-09-00:00: inline quick-entry bar carries the same per-task thinking-level override as the full New Task modal; "" means "use default". */
const [thinkingLevel, setThinkingLevel] = useState<string>("");
const [validatorThinkingLevel, setValidatorThinkingLevel] = useState<string>("");
@@ -681,12 +685,16 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
setPriorityPickerPosition(null);
setExecutorProvider(undefined);
setExecutorModelId(undefined);
setCredentialInstanceId(undefined);
setValidatorProvider(undefined);
setValidatorModelId(undefined);
setValidatorCredentialInstanceId(undefined);
setPlanningProvider(undefined);
setPlanningModelId(undefined);
setPlanningCredentialInstanceId(undefined);
setMergerProvider(undefined);
setMergerModelId(undefined);
setMergerCredentialInstanceId(undefined);
setThinkingLevel("");
setValidatorThinkingLevel("");
setPlanningThinkingLevel("");
@@ -817,12 +825,16 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
modelPresetId: selectedPresetId,
modelProvider: hasExecutorOverride ? executorProvider : undefined,
modelId: hasExecutorOverride ? executorModelId : undefined,
...(hasExecutorOverride && credentialInstanceId ? { credentialInstanceId } : {}),
validatorModelProvider: hasValidatorOverride ? validatorProvider : undefined,
validatorModelId: hasValidatorOverride ? validatorModelId : undefined,
...(hasValidatorOverride && validatorCredentialInstanceId ? { validatorCredentialInstanceId } : {}),
planningModelProvider: hasPlanningOverride ? planningProvider : undefined,
planningModelId: hasPlanningOverride ? planningModelId : undefined,
...(hasPlanningOverride && planningCredentialInstanceId ? { planningCredentialInstanceId } : {}),
mergerModelProvider: hasMergerOverride ? mergerProvider : undefined,
mergerModelId: hasMergerOverride ? mergerModelId : undefined,
...(hasMergerOverride && mergerCredentialInstanceId ? { mergerCredentialInstanceId } : {}),
validatorThinkingLevel: validatorThinkingLevel !== "" ? (validatorThinkingLevel as ThinkingLevel) : undefined,
planningThinkingLevel: planningThinkingLevel !== "" ? (planningThinkingLevel as ThinkingLevel) : undefined,
mergerThinkingLevel: mergerThinkingLevel !== "" ? (mergerThinkingLevel as ThinkingLevel) : undefined,
@@ -901,12 +913,16 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
hasExecutorOverride,
executorProvider,
executorModelId,
credentialInstanceId,
hasValidatorOverride,
validatorProvider,
validatorModelId,
validatorCredentialInstanceId,
hasPlanningOverride,
planningProvider,
planningModelId,
planningCredentialInstanceId,
mergerCredentialInstanceId,
thinkingLevel,
enabledOptionalStepIds,
isFastMode,
@@ -1462,18 +1478,21 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
const handlePlanningModelChange = useCallback((value: string) => {
const next = parseModelSelection(value);
setPlanningCredentialInstanceId(undefined);
setPlanningProvider(next.provider);
setPlanningModelId(next.modelId);
}, []);
const handleExecutorChange = useCallback((value: string) => {
const next = parseModelSelection(value);
setCredentialInstanceId(undefined);
setExecutorProvider(next.provider);
setExecutorModelId(next.modelId);
}, []);
const handleValidatorChange = useCallback((value: string) => {
const next = parseModelSelection(value);
setValidatorCredentialInstanceId(undefined);
setValidatorProvider(next.provider);
setValidatorModelId(next.modelId);
}, []);
@@ -1481,6 +1500,7 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
const handleThinkingLevelChange = useCallback((value: string) => setThinkingLevel(value), []);
const handleMergerModelChange = useCallback((value: string) => {
const next = parseModelSelection(value);
setMergerCredentialInstanceId(undefined);
setMergerProvider(next.provider);
setMergerModelId(next.modelId);
}, []);
@@ -2498,6 +2518,14 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
thinkingLevel={activeModelSubmenu === "executor" ? thinkingLevel : activeModelSubmenu === "plan" ? planningThinkingLevel : activeModelSubmenu === "validator" ? validatorThinkingLevel : mergerThinkingLevel}
onThinkingLevelChange={activeModelSubmenu === "executor" ? handleThinkingLevelChange : activeModelSubmenu === "plan" ? setPlanningThinkingLevel : activeModelSubmenu === "validator" ? setValidatorThinkingLevel : setMergerThinkingLevel}
defaultThinkingLevel={settings?.defaultThinkingLevel ?? "off"}
credentialInstanceId={activeModelSubmenu === "plan" ? planningCredentialInstanceId : activeModelSubmenu === "executor" ? credentialInstanceId : activeModelSubmenu === "validator" ? validatorCredentialInstanceId : mergerCredentialInstanceId}
onCredentialInstanceChange={(instanceId) => {
const next = instanceId || undefined;
if (activeModelSubmenu === "plan") setPlanningCredentialInstanceId(next);
else if (activeModelSubmenu === "executor") setCredentialInstanceId(next);
else if (activeModelSubmenu === "validator") setValidatorCredentialInstanceId(next);
else setMergerCredentialInstanceId(next);
}}
/>
{modelsError && (
<div className="model-submenu-error">

View File

@@ -3877,8 +3877,10 @@ export function SettingsModal({
name: nextName,
executorProvider: presetDraft.executorProvider,
executorModelId: presetDraft.executorModelId,
...(presetDraft.executorCredentialInstanceId ? { executorCredentialInstanceId: presetDraft.executorCredentialInstanceId } : {}),
validatorProvider: presetDraft.validatorProvider,
validatorModelId: presetDraft.validatorModelId,
...(presetDraft.validatorCredentialInstanceId ? { validatorCredentialInstanceId: presetDraft.validatorCredentialInstanceId } : {}),
};
setForm((current) => {

View File

@@ -1390,8 +1390,11 @@ export function TaskDetailContent({
const [editBranch, setEditBranch] = useState(task.branch ?? "");
const [editBaseBranch, setEditBaseBranch] = useState(task.baseBranch ?? "");
const [editExecutorModel, setEditExecutorModel] = useState("");
const [editCredentialInstanceId, setEditCredentialInstanceId] = useState<string | undefined>(undefined);
const [editValidatorModel, setEditValidatorModel] = useState("");
const [editValidatorCredentialInstanceId, setEditValidatorCredentialInstanceId] = useState<string | undefined>(undefined);
const [editPlanningModel, setEditPlanningModel] = useState("");
const [editPlanningCredentialInstanceId, setEditPlanningCredentialInstanceId] = useState<string | undefined>(undefined);
const [editThinkingLevel, setEditThinkingLevel] = useState("");
// FNXC:PlannerOversight 2026-07-04-00:00: Per-task override of the workflow-native plannerOversightLevel setting (FN-7508). "" means "inherit from workflow" (clear-to-default).
const [editPlannerOversightLevel, setEditPlannerOversightLevel] = useState("");
@@ -2144,8 +2147,11 @@ export function TaskDetailContent({
const valModel = task.validatorModelProvider && task.validatorModelId ? `${task.validatorModelProvider}/${task.validatorModelId}` : "";
const planModel = task.planningModelProvider && task.planningModelId ? `${task.planningModelProvider}/${task.planningModelId}` : "";
setEditExecutorModel(execModel);
setEditCredentialInstanceId(task.credentialInstanceId);
setEditValidatorModel(valModel);
setEditValidatorCredentialInstanceId(task.validatorCredentialInstanceId);
setEditPlanningModel(planModel);
setEditPlanningCredentialInstanceId(task.planningCredentialInstanceId);
setEditThinkingLevel(task.thinkingLevel ?? "");
setEditPlannerOversightLevel(task.plannerOversightLevel ?? "");
setEditNodeId(task.nodeId);
@@ -2208,6 +2214,9 @@ export function TaskDetailContent({
if (editExecutorModel !== currentExecutorModel) {
updates.modelProvider = executorSelection?.provider ?? null;
updates.modelId = executorSelection?.modelId ?? null;
updates.credentialInstanceId = null;
} else if ((editCredentialInstanceId ?? "") !== (task.credentialInstanceId ?? "")) {
updates.credentialInstanceId = editCredentialInstanceId ?? null;
}
const validatorSelection = splitModelSelection(editValidatorModel);
@@ -2215,6 +2224,9 @@ export function TaskDetailContent({
if (editValidatorModel !== currentValidatorModel) {
updates.validatorModelProvider = validatorSelection?.provider ?? null;
updates.validatorModelId = validatorSelection?.modelId ?? null;
updates.validatorCredentialInstanceId = null;
} else if ((editValidatorCredentialInstanceId ?? "") !== (task.validatorCredentialInstanceId ?? "")) {
updates.validatorCredentialInstanceId = editValidatorCredentialInstanceId ?? null;
}
const planningSelection = splitModelSelection(editPlanningModel);
@@ -2222,6 +2234,9 @@ export function TaskDetailContent({
if (editPlanningModel !== currentPlanningModel) {
updates.planningModelProvider = planningSelection?.provider ?? null;
updates.planningModelId = planningSelection?.modelId ?? null;
updates.planningCredentialInstanceId = null;
} else if ((editPlanningCredentialInstanceId ?? "") !== (task.planningCredentialInstanceId ?? "")) {
updates.planningCredentialInstanceId = editPlanningCredentialInstanceId ?? null;
}
const currentThinkingLevel = task.thinkingLevel ?? "";
@@ -2269,7 +2284,7 @@ export function TaskDetailContent({
}
return { updates, error: null as string | null };
}, [editBaseBranch, editBranch, editDependencies, editDescription, editExecutionMode, editExecutorModel, editNodeId, editPlanningModel, editPriority, editReviewLevel, editSelectedWorkflowSteps, editSourceIssueExternalId, editSourceIssueProvider, editSourceIssueRepository, editSourceIssueUrl, editThinkingLevel, editPlannerOversightLevel, editTitle, editValidatorModel, task]);
}, [editBaseBranch, editBranch, editDependencies, editDescription, editExecutionMode, editCredentialInstanceId, editExecutorModel, editNodeId, editPlanningCredentialInstanceId, editPlanningModel, editPriority, editReviewLevel, editSelectedWorkflowSteps, editSourceIssueExternalId, editSourceIssueProvider, editSourceIssueRepository, editSourceIssueUrl, editThinkingLevel, editPlannerOversightLevel, editTitle, editValidatorCredentialInstanceId, editValidatorModel, task]);
const persistEditChanges = useCallback(async (includeDescription: boolean) => {
const { updates, error } = buildEditUpdates(includeDescription);
@@ -2370,8 +2385,11 @@ export function TaskDetailContent({
editBranch,
editBaseBranch,
editExecutorModel,
editCredentialInstanceId,
editValidatorModel,
editValidatorCredentialInstanceId,
editPlanningModel,
editPlanningCredentialInstanceId,
editThinkingLevel,
editPlannerOversightLevel,
editNodeId,
@@ -4532,11 +4550,17 @@ export function TaskDetailContent({
baseBranch={editBaseBranch}
onBaseBranchChange={setEditBaseBranch}
executorModel={editExecutorModel}
onExecutorModelChange={setEditExecutorModel}
onExecutorModelChange={(value) => { setEditCredentialInstanceId(undefined); setEditExecutorModel(value); }}
credentialInstanceId={editCredentialInstanceId}
onCredentialInstanceIdChange={(instanceId) => setEditCredentialInstanceId(instanceId || undefined)}
validatorModel={editValidatorModel}
onValidatorModelChange={setEditValidatorModel}
onValidatorModelChange={(value) => { setEditValidatorCredentialInstanceId(undefined); setEditValidatorModel(value); }}
validatorCredentialInstanceId={editValidatorCredentialInstanceId}
onValidatorCredentialInstanceIdChange={(instanceId) => setEditValidatorCredentialInstanceId(instanceId || undefined)}
planningModel={editPlanningModel}
onPlanningModelChange={setEditPlanningModel}
onPlanningModelChange={(value) => { setEditPlanningCredentialInstanceId(undefined); setEditPlanningModel(value); }}
planningCredentialInstanceId={editPlanningCredentialInstanceId}
onPlanningCredentialInstanceIdChange={(instanceId) => setEditPlanningCredentialInstanceId(instanceId || undefined)}
thinkingLevel={editThinkingLevel}
onThinkingLevelChange={setEditThinkingLevel}
plannerOversightLevel={editPlannerOversightLevel}

View File

@@ -97,10 +97,16 @@ export interface TaskFormProps {
onPriorityChange?: (value: TaskPriority) => void;
executorModel: string;
onExecutorModelChange: (value: string, meta?: TaskFormValueChangeMeta) => void;
credentialInstanceId?: string;
onCredentialInstanceIdChange?: (value: string) => void;
validatorModel: string;
onValidatorModelChange: (value: string, meta?: TaskFormValueChangeMeta) => void;
validatorCredentialInstanceId?: string;
onValidatorCredentialInstanceIdChange?: (value: string) => void;
planningModel?: string;
onPlanningModelChange?: (value: string) => void;
planningCredentialInstanceId?: string;
onPlanningCredentialInstanceIdChange?: (value: string) => void;
thinkingLevel?: string;
onThinkingLevelChange?: (value: string) => void;
/*
@@ -216,10 +222,16 @@ export function TaskForm({
onPriorityChange,
executorModel,
onExecutorModelChange,
credentialInstanceId,
onCredentialInstanceIdChange,
validatorModel,
onValidatorModelChange,
validatorCredentialInstanceId,
onValidatorCredentialInstanceIdChange,
planningModel,
onPlanningModelChange,
planningCredentialInstanceId,
onPlanningCredentialInstanceIdChange,
thinkingLevel,
onThinkingLevelChange,
plannerOversightLevel,
@@ -1571,6 +1583,8 @@ export function TaskForm({
thinkingLevel={thinkingLevel || ""}
onThinkingLevelChange={onThinkingLevelChange ? (value) => onThinkingLevelChange(value) : undefined}
defaultThinkingLevel={settings?.defaultThinkingLevel ?? "off"}
credentialInstanceId={credentialInstanceId}
onCredentialInstanceChange={onCredentialInstanceIdChange}
/>
</div>
<div className="model-select-row">
@@ -1591,6 +1605,8 @@ export function TaskForm({
onToggleFavorite={handleToggleFavorite}
favoriteModels={favoriteModels}
onToggleModelFavorite={handleToggleModelFavorite}
credentialInstanceId={validatorCredentialInstanceId}
onCredentialInstanceChange={onValidatorCredentialInstanceIdChange}
/>
</div>
{onPlanningModelChange && (
@@ -1611,6 +1627,8 @@ export function TaskForm({
onToggleFavorite={handleToggleFavorite}
favoriteModels={favoriteModels}
onToggleModelFavorite={handleToggleModelFavorite}
credentialInstanceId={planningCredentialInstanceId}
onCredentialInstanceChange={onPlanningCredentialInstanceIdChange}
/>
</div>
)}

View File

@@ -4364,8 +4364,15 @@ function InnerEditor({
)}
onChange={(value) => {
const { provider, modelId } = parseModelDropdownValue(value);
updateSelectedData({ config: { modelProvider: provider || undefined, modelId: modelId || undefined } });
updateSelectedData({ config: {
modelProvider: provider || undefined,
modelId: modelId || undefined,
// FNXC:ModelDropdown 2026-08-01-10:45: Explicit node-model changes cannot retain an instance selected for the previous provider.
credentialInstanceId: undefined,
} });
}}
credentialInstanceId={String(selectedNode.data.config?.credentialInstanceId ?? "")}
onCredentialInstanceChange={(credentialInstanceId) => updateSelectedData({ config: { credentialInstanceId: credentialInstanceId || undefined } })}
/*
* FNXC:Settings-ThinkingLevel 2026-07-10-00:00:
* Prompt model nodes expose the shared inline thinking selector only for the model executor, persisting `config.thinkingLevel` with Default clearing the key.
@@ -5102,9 +5109,13 @@ function InnerEditor({
modelProvider: provider || undefined,
modelId: modelId || undefined,
model: value || undefined,
// FNXC:ModelDropdown 2026-08-01-10:45: Review-node model changes clear the prior credential-instance override.
credentialInstanceId: undefined,
},
});
}}
credentialInstanceId={String(selectedNode.data.config?.credentialInstanceId ?? "")}
onCredentialInstanceChange={(credentialInstanceId) => updateSelectedData({ config: { credentialInstanceId: credentialInstanceId || undefined } })}
/*
* FNXC:Settings-ThinkingLevel 2026-07-10-00:00:
* Step-review nodes share the model dropdown thinking selector so review sessions can pin reasoning effort with the same node > task > settings precedence as executor steps.

View File

@@ -505,6 +505,7 @@ export interface WorkflowModelLanePair {
providerId: string;
modelId: string;
thinkingId?: string;
credentialInstanceId?: string;
label: string;
help: string;
}
@@ -525,6 +526,7 @@ export const WORKFLOW_MODEL_LANE_CATALOG: WorkflowModelLanePair[] = [
providerId: "planningProvider",
modelId: "planningModelId",
thinkingId: "planningThinkingLevel",
credentialInstanceId: "planningCredentialInstanceId",
label: "Plan/Triage Model",
help: "Provider and model used when planning or triaging tasks. Leave unset to inherit from the default lane.",
},
@@ -533,6 +535,7 @@ export const WORKFLOW_MODEL_LANE_CATALOG: WorkflowModelLanePair[] = [
providerId: "planningFallbackProvider",
modelId: "planningFallbackModelId",
thinkingId: "planningFallbackThinkingLevel",
credentialInstanceId: "planningFallbackCredentialInstanceId",
label: "Planning Fallback Model",
help: "Fallback provider and model used when the primary Plan/Triage model cannot be used.",
},
@@ -541,6 +544,7 @@ export const WORKFLOW_MODEL_LANE_CATALOG: WorkflowModelLanePair[] = [
providerId: "executionProvider",
modelId: "executionModelId",
thinkingId: "executionThinkingLevel",
credentialInstanceId: "executionCredentialInstanceId",
label: "Executor Model",
help: "Provider and model used by task implementation agents. Leave unset to inherit from the default lane.",
},
@@ -549,6 +553,7 @@ export const WORKFLOW_MODEL_LANE_CATALOG: WorkflowModelLanePair[] = [
providerId: "executionFallbackProvider",
modelId: "executionFallbackModelId",
thinkingId: "executionFallbackThinkingLevel",
credentialInstanceId: "executionFallbackCredentialInstanceId",
label: "Executor Fallback Model",
help: "Fallback provider and model used when the primary Executor model cannot be used.",
},
@@ -557,6 +562,7 @@ export const WORKFLOW_MODEL_LANE_CATALOG: WorkflowModelLanePair[] = [
providerId: "validatorProvider",
modelId: "validatorModelId",
thinkingId: "validatorThinkingLevel",
credentialInstanceId: "validatorCredentialInstanceId",
label: "Reviewer Model",
help: "Provider and model used by review and validation agents. Leave unset to inherit from the default lane.",
},
@@ -565,6 +571,7 @@ export const WORKFLOW_MODEL_LANE_CATALOG: WorkflowModelLanePair[] = [
providerId: "validatorFallbackProvider",
modelId: "validatorFallbackModelId",
thinkingId: "validatorFallbackThinkingLevel",
credentialInstanceId: "validatorFallbackCredentialInstanceId",
label: "Reviewer Fallback Model",
help: "Fallback provider and model used when the primary Reviewer model cannot be used.",
},
@@ -619,7 +626,12 @@ function ValuesTab({
[settingsById],
);
const modelPairSettingIds = useMemo(
() => new Set(modelLanePairs.flatMap((pair) => [pair.providerId, pair.modelId, ...(pair.thinkingId ? [pair.thinkingId] : [])])),
() => new Set(modelLanePairs.flatMap((pair) => [
pair.providerId,
pair.modelId,
...(pair.thinkingId ? [pair.thinkingId] : []),
...(pair.credentialInstanceId ? [pair.credentialInstanceId] : []),
])),
[modelLanePairs],
);
@@ -727,6 +739,8 @@ function ValuesTab({
...prev,
[pair.providerId]: split?.provider ?? null,
[pair.modelId]: split?.modelId ?? null,
// FNXC:ModelDropdown 2026-08-01-10:45: Persisted workflow lane selections clear any instance from the prior provider/model pair.
...(pair.credentialInstanceId ? { [pair.credentialInstanceId]: null } : {}),
}));
setRejections((prev) => {
if (!prev[pair.providerId] && !prev[pair.modelId]) return prev;
@@ -737,6 +751,12 @@ function ValuesTab({
});
};
const setModelPairCredentialInstanceValue = (pair: WorkflowModelLanePair, value: string) => {
if (!pair.credentialInstanceId) return;
// Default clears the stored override; workflow setting writes use null-as-delete.
setValue(pair.credentialInstanceId, value || null);
};
const setModelPairThinkingValue = (pair: WorkflowModelLanePair, value: string) => {
if (!pair.thinkingId) return;
setValue(pair.thinkingId, value || null);
@@ -745,6 +765,7 @@ function ValuesTab({
const clearModelPairValue = (pair: WorkflowModelLanePair) => {
setModelPairValue(pair, "");
setModelPairThinkingValue(pair, "");
setModelPairCredentialInstanceValue(pair, "");
};
const dirty = Object.keys(pending).length > 0;
@@ -807,8 +828,17 @@ function ValuesTab({
const thinkingValue = pair.thinkingId && typeof valueOfSettingId(pair.thinkingId) === "string"
? (valueOfSettingId(pair.thinkingId) as string)
: "";
const error = rejections[pair.providerId]?.message ?? rejections[pair.modelId]?.message ?? (pair.thinkingId ? rejections[pair.thinkingId]?.message : undefined);
const customized = isCustomizedId(pair.providerId) || isCustomizedId(pair.modelId) || Boolean(pair.thinkingId && isCustomizedId(pair.thinkingId));
const credentialInstanceId = pair.credentialInstanceId && typeof valueOfSettingId(pair.credentialInstanceId) === "string"
? (valueOfSettingId(pair.credentialInstanceId) as string)
: "";
const error = rejections[pair.providerId]?.message
?? rejections[pair.modelId]?.message
?? (pair.thinkingId ? rejections[pair.thinkingId]?.message : undefined)
?? (pair.credentialInstanceId ? rejections[pair.credentialInstanceId]?.message : undefined);
const customized = isCustomizedId(pair.providerId)
|| isCustomizedId(pair.modelId)
|| Boolean(pair.thinkingId && isCustomizedId(pair.thinkingId))
|| Boolean(pair.credentialInstanceId && isCustomizedId(pair.credentialInstanceId));
const dropdownDisabled = modelsLoading || availableModels.length === 0;
const emptyHelp =
!modelsLoading && availableModels.length === 0
@@ -833,6 +863,8 @@ function ValuesTab({
models={availableModels}
value={value}
onChange={(next) => setModelPairValue(pair, next)}
credentialInstanceId={credentialInstanceId}
onCredentialInstanceChange={(value) => setModelPairCredentialInstanceValue(pair, value)}
placeholder={t("workflowSettings.selectModel", "Select a model…")}
defaultOptionLabel={t("workflowSettings.useInheritedModel", "Use inherited/default model")}
disabled={dropdownDisabled}

View File

@@ -0,0 +1,111 @@
import type { ComponentProps } from "react";
import { describe, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { CustomModelDropdown } from "../CustomModelDropdown";
vi.mock("../ProviderIcon", () => ({
ProviderIcon: () => <span />,
}));
const models = [{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 }];
async function openDropdown(props: Partial<ComponentProps<typeof CustomModelDropdown>>) {
const user = userEvent.setup();
render(<CustomModelDropdown label="Model" value="openai/gpt-4o" onChange={vi.fn()} models={models} {...props} />);
await user.click(screen.getByRole("button", { name: "Model" }));
return user;
}
describe("CustomModelDropdown credential instance", () => {
it.each([undefined, {}, { openai: { instances: [] } }, { openai: { instances: [{ id: "only", isDefault: true }] } }])(
"renders no instance control unless the selected provider has two instances",
async (credentialInstances) => {
await openDropdown({ credentialInstances });
expect(screen.queryByTestId("custom-model-dropdown-credential-instance")).toBeNull();
expect(screen.queryByTestId("custom-model-dropdown-credential-instance-badge")).toBeNull();
},
);
it("keeps hidden menus structurally free of an instance shell at desktop and mobile breakpoints", async () => {
for (const width of [1024, 768, 480]) {
Object.defineProperty(window, "innerWidth", { configurable: true, value: width });
const user = await openDropdown({ credentialInstances: { openai: { instances: [{ id: "only", isDefault: true }] } } });
const menu = screen.getAllByTestId("model-combobox-portal").at(-1)!;
expect([...menu.children].map((element) => element.className)).not.toContain("model-combobox-instance");
expect(screen.queryByTestId("custom-model-dropdown-credential-instance")).toBeNull();
expect(screen.queryByTestId("custom-model-dropdown-credential-instance-badge")).toBeNull();
await user.keyboard("{Escape}");
cleanup();
}
});
it("shows availability-driven control even before an owner supplies a persistence callback", async () => {
await openDropdown({
credentialInstances: { openai: { instances: [{ id: "primary", isDefault: true }, { id: "backup", isDefault: false }] } },
});
expect(screen.getByTestId("custom-model-dropdown-credential-instance")).toBeDisabled();
});
it("deduplicates instances, preserves a stale selection, and emits empty only for Default", async () => {
const onCredentialInstanceChange = vi.fn();
const user = await openDropdown({
credentialInstanceId: "stale",
onCredentialInstanceChange,
credentialInstances: {
openai: { instances: [{ id: "primary", isDefault: true }, { id: "primary", isDefault: false }, { id: "backup", isDefault: false }] },
},
});
const select = screen.getByTestId("custom-model-dropdown-credential-instance") as HTMLSelectElement;
expect([...select.options].map((option) => option.value)).toEqual(["", "primary", "backup", "stale"]);
expect(select.value).toBe("stale");
await user.selectOptions(select, "backup");
expect(onCredentialInstanceChange).toHaveBeenLastCalledWith("backup");
await user.selectOptions(select, "");
expect(onCredentialInstanceChange).toHaveBeenLastCalledWith("");
});
it("recomputes availability after the selected provider changes without affecting keyboard dismissal", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const onCredentialInstanceChange = vi.fn();
const { rerender } = render(
<CustomModelDropdown
label="Model"
value="openai/gpt-4o"
onChange={onChange}
models={[
...models,
{ provider: "anthropic", id: "claude", name: "Claude", reasoning: false, contextWindow: 128000 },
]}
credentialInstances={{
openai: { instances: [{ id: "primary", isDefault: true }, { id: "backup", isDefault: false }] },
anthropic: { instances: [{ id: "only", isDefault: true }] },
}}
onCredentialInstanceChange={onCredentialInstanceChange}
/>,
);
await user.click(screen.getByRole("button", { name: "Model" }));
expect(screen.getByTestId("custom-model-dropdown-credential-instance")).toBeTruthy();
rerender(
<CustomModelDropdown
label="Model"
value="anthropic/claude"
onChange={onChange}
models={[
...models,
{ provider: "anthropic", id: "claude", name: "Claude", reasoning: false, contextWindow: 128000 },
]}
credentialInstances={{
openai: { instances: [{ id: "primary", isDefault: true }, { id: "backup", isDefault: false }] },
anthropic: { instances: [{ id: "only", isDefault: true }] },
}}
onCredentialInstanceChange={onCredentialInstanceChange}
/>,
);
expect(screen.queryByTestId("custom-model-dropdown-credential-instance")).toBeNull();
await user.keyboard("{Escape}");
expect(screen.queryByTestId("model-combobox-portal")).toBeNull();
});
});

View File

@@ -705,7 +705,7 @@ describe("WorkflowSettingsPanel — Values tab", () => {
await waitFor(() => expect(mockUpdateValues).toHaveBeenCalledTimes(1));
expect(mockUpdateValues).toHaveBeenCalledWith(
"wf-1",
{ planningProvider: "anthropic", planningModelId: "claude-sonnet" },
{ planningProvider: "anthropic", planningModelId: "claude-sonnet", planningCredentialInstanceId: null },
"proj-1",
);
});
@@ -759,6 +759,7 @@ describe("WorkflowSettingsPanel — Values tab", () => {
validatorFallbackProvider: null,
validatorFallbackModelId: null,
validatorFallbackThinkingLevel: null,
validatorFallbackCredentialInstanceId: null,
}, "proj-1");
});
@@ -788,7 +789,7 @@ describe("WorkflowSettingsPanel — Values tab", () => {
await waitFor(() => expect(mockUpdateValues).toHaveBeenCalledTimes(1));
expect(mockUpdateValues).toHaveBeenCalledWith(
"wf-1",
{ planningProvider: null, planningModelId: null },
{ planningProvider: null, planningModelId: null, planningCredentialInstanceId: null },
"proj-1",
);
});
@@ -830,7 +831,7 @@ describe("WorkflowSettingsPanel — Values tab", () => {
expect(mockUpdateValues).toHaveBeenNthCalledWith(
2,
"wf-1",
{ planningProvider: "anthropic", planningModelId: "claude-sonnet" },
{ planningProvider: "anthropic", planningModelId: "claude-sonnet", planningCredentialInstanceId: null },
"proj-1",
);
});
@@ -871,7 +872,7 @@ describe("WorkflowSettingsPanel — Values tab", () => {
expect(within(row).getByLabelText("Plan/Triage Model")).toHaveTextContent("Claude Sonnet");
expect(mockUpdateValues).toHaveBeenCalledWith(
"wf-1",
{ planningProvider: "anthropic", planningModelId: "claude-sonnet" },
{ planningProvider: "anthropic", planningModelId: "claude-sonnet", planningCredentialInstanceId: null },
"proj-1",
);
});

View File

@@ -83,9 +83,11 @@ export function extractAssignedRuntimeModel(agent: Agent | null | undefined): Mo
const provider = isStringValue(runtimeConfig?.modelProvider) ? runtimeConfig.modelProvider.trim() : "";
const modelId = isStringValue(runtimeConfig?.modelId) ? runtimeConfig.modelId.trim() : "";
const credentialInstanceId = isStringValue(runtimeConfig?.credentialInstanceId) ? runtimeConfig.credentialInstanceId.trim() : "";
return {
provider: provider || undefined,
modelId: modelId || undefined,
...(credentialInstanceId ? { credentialInstanceId } : {}),
};
}
@@ -191,7 +193,11 @@ export function resolveEffectivePlanning(
settings?: Settings,
): ModelSelection {
if (task.planningModelProvider && task.planningModelId) {
return { provider: task.planningModelProvider, modelId: task.planningModelId };
return {
provider: task.planningModelProvider,
modelId: task.planningModelId,
...(task.planningCredentialInstanceId ? { credentialInstanceId: task.planningCredentialInstanceId } : {}),
};
}
const fromLog = extractPlanningModelFromLog(logEntries);
if (fromLog) {

View File

@@ -17,6 +17,18 @@ function toCommaSeparatedInput(values?: string[]): string {
function fromCommaSeparatedInput(value: string): string[] {
return value.split(",").map((item) => item.trim()).filter((item) => item.length > 0);
}
/*
FNXC:SettingsCredentialInstance 2026-08-01-10:19:
Each global model lane persists its credential instance alongside its provider/model pair. Clearing either the model or the instance must remove the override so runtime resolution returns to the provider default rather than retaining an orphaned credential choice.
*/
const GLOBAL_LANE_CREDENTIAL_INSTANCE_KEYS: Partial<Record<string, keyof Settings>> = {
execution: "executionGlobalCredentialInstanceId",
planning: "planningGlobalCredentialInstanceId",
validator: "validatorGlobalCredentialInstanceId",
merger: "mergerGlobalCredentialInstanceId",
summarization: "titleSummarizerGlobalCredentialInstanceId",
"import-translate": "importTranslateGlobalCredentialInstanceId",
};
export interface GlobalModelsSectionProps extends SectionBaseProps {
availableModels: ModelInfo[];
modelsLoading: boolean;
@@ -55,7 +67,7 @@ export function GlobalModelsSection({ form, setForm, availableModels, modelsLoad
</div>
<CustomModelDropdown id="defaultModel" label="Default Model" models={availableModels} value={selectedValue} onChange={(val) => {
if (!val) {
setForm((f) => ({ ...f, defaultProvider: undefined, defaultModelId: undefined }));
setForm((f) => ({ ...f, defaultProvider: undefined, defaultModelId: undefined, defaultCredentialInstanceId: undefined }));
}
else {
const slashIdx = val.indexOf("/");
@@ -63,9 +75,11 @@ export function GlobalModelsSection({ form, setForm, availableModels, modelsLoad
...f,
defaultProvider: val.slice(0, slashIdx),
defaultModelId: val.slice(slashIdx + 1),
// FNXC:ModelDropdown 2026-08-01-10:45: A provider/model change invalidates its credential-instance companion; preserve stale ids only during passive rendering, never across an explicit selection.
defaultCredentialInstanceId: undefined,
}));
}
}} placeholder={t("settings.globalModels.useDefault", "Use default")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite}/>
}} credentialInstanceId={form.defaultCredentialInstanceId} onCredentialInstanceChange={(instanceId) => setForm((f) => ({ ...f, defaultCredentialInstanceId: instanceId || undefined }))} placeholder={t("settings.globalModels.useDefault", "Use default")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite}/>
</div>
<div className="form-group">
@@ -76,7 +90,7 @@ export function GlobalModelsSection({ form, setForm, availableModels, modelsLoad
{/* 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 }));
setForm((f) => ({ ...f, fallbackProvider: undefined, fallbackModelId: undefined, fallbackThinkingLevel: undefined, fallbackCredentialInstanceId: undefined }));
}
else {
const slashIdx = val.indexOf("/");
@@ -84,9 +98,11 @@ export function GlobalModelsSection({ form, setForm, availableModels, modelsLoad
...f,
fallbackProvider: val.slice(0, slashIdx),
fallbackModelId: val.slice(slashIdx + 1),
// FNXC:ModelDropdown 2026-08-01-10:45: Explicit model selection clears the prior provider's credential-instance override.
fallbackCredentialInstanceId: undefined,
}));
}
}} placeholder={t("settings.globalModels.noFallback", "No fallback")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} showThinkingLevel={(() => {
}} credentialInstanceId={form.fallbackCredentialInstanceId} onCredentialInstanceChange={(instanceId) => setForm((f) => ({ ...f, fallbackCredentialInstanceId: instanceId || undefined }))} 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}/>
@@ -129,6 +145,8 @@ export function GlobalModelsSection({ form, setForm, availableModels, modelsLoad
const model = form[lane.globalModelKey as keyof Settings] as string | undefined;
const value = provider && model ? `${provider}/${model}` : "";
const thinkingValue = getLaneThinkingValue(lane);
const credentialInstanceKey = GLOBAL_LANE_CREDENTIAL_INSTANCE_KEYS[lane.laneId];
const credentialInstanceId = credentialInstanceKey ? form[credentialInstanceKey] as string | undefined : undefined;
return (<div className="form-group" key={`global-${lane.laneId}`}>
{/* FNXC:SettingsHelp 2026-07-15-21:40: A global lane row is plain label + picker + one help string (unlike the project lanes, which add an inherited/override badge and a resolved fallback chain), so its helper text hangs off the shared "?" like every other row in this section. */}
<div className="settings-field-label-row">
@@ -141,6 +159,7 @@ export function GlobalModelsSection({ form, setForm, availableModels, modelsLoad
...f,
[lane.globalProviderKey]: undefined,
[lane.globalModelKey]: undefined,
...(credentialInstanceKey ? { [credentialInstanceKey]: undefined } : {}),
}));
resetLaneThinkingValue(lane);
return;
@@ -150,8 +169,10 @@ export function GlobalModelsSection({ form, setForm, availableModels, modelsLoad
...f,
[lane.globalProviderKey]: selected.slice(0, slashIdx),
[lane.globalModelKey]: selected.slice(slashIdx + 1),
// FNXC:ModelDropdown 2026-08-01-10:45: Lane selections cannot carry an instance override from the previously chosen provider.
...(credentialInstanceKey ? { [credentialInstanceKey]: undefined } : {}),
}));
}} 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}/>
}} credentialInstanceId={credentialInstanceId} onCredentialInstanceChange={credentialInstanceKey ? (instanceId) => setForm((f) => ({ ...f, [credentialInstanceKey]: instanceId || undefined })) : undefined} 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}/>
</div>);
})}
</>)}

View File

@@ -19,10 +19,21 @@ type WorkflowModelPair = {
providerId: string;
modelId: string;
thinkingId?: string;
credentialInstanceId?: string;
label: string;
help: string;
};
const DEFAULT_WORKFLOW_ID = "builtin:coding";
/*
FNXC:SettingsCredentialInstance 2026-08-01-10:19:
Project lanes that own persisted model overrides must carry their credential instance through the same form state. The empty Default choice deletes only that instance key, preserving provider/model selection while returning resolution to the provider default.
*/
const PROJECT_LANE_CREDENTIAL_INSTANCE_KEYS = {
default: "defaultCredentialInstanceIdOverride",
merger: "mergerCredentialInstanceId",
"import-translate": "importTranslateCredentialInstanceId",
} as const satisfies Partial<Record<string, keyof Settings>>;
/*
FNXC:SettingsModels 2026-06-16-19:58:
Fallback model lanes must be configurable in all Settings surfaces: General uses the global Fallback Model, Workflow Values uses declared workflow settings, and Project Models exposes workflow fallback pairs declared by the active default workflow plus project-scoped title-summarizer fallback keys so saves never PATCH undeclared keys.
@@ -39,6 +50,7 @@ const WORKFLOW_MODEL_PAIRS: WorkflowModelPair[] = [
providerId: "planningProvider",
modelId: "planningModelId",
thinkingId: "planningThinkingLevel",
credentialInstanceId: "planningCredentialInstanceId",
label: "Plan/Triage Model",
help: "Provider and model used when planning or triaging tasks. Leave unset to fall through to the global lane, then the selected workflow.",
},
@@ -47,6 +59,7 @@ const WORKFLOW_MODEL_PAIRS: WorkflowModelPair[] = [
providerId: "planningFallbackProvider",
modelId: "planningFallbackModelId",
thinkingId: "planningFallbackThinkingLevel",
credentialInstanceId: "planningFallbackCredentialInstanceId",
label: "Planning Fallback Model",
help: "Fallback provider and model used when the primary Plan/Triage model cannot be used.",
},
@@ -55,6 +68,7 @@ const WORKFLOW_MODEL_PAIRS: WorkflowModelPair[] = [
providerId: "executionProvider",
modelId: "executionModelId",
thinkingId: "executionThinkingLevel",
credentialInstanceId: "executionCredentialInstanceId",
label: "Executor Model",
help: "Provider and model used while executing workflow steps. Leave unset to fall through to the global lane, then the selected workflow.",
},
@@ -63,6 +77,7 @@ const WORKFLOW_MODEL_PAIRS: WorkflowModelPair[] = [
providerId: "executionFallbackProvider",
modelId: "executionFallbackModelId",
thinkingId: "executionFallbackThinkingLevel",
credentialInstanceId: "executionFallbackCredentialInstanceId",
label: "Executor Fallback Model",
help: "Fallback provider and model used when the primary Executor model cannot be used.",
},
@@ -71,6 +86,7 @@ const WORKFLOW_MODEL_PAIRS: WorkflowModelPair[] = [
providerId: "validatorProvider",
modelId: "validatorModelId",
thinkingId: "validatorThinkingLevel",
credentialInstanceId: "validatorCredentialInstanceId",
label: "Reviewer Model",
help: "Provider and model used for workflow review or validation lanes. Leave unset to fall through to the global lane, then the selected workflow.",
},
@@ -79,6 +95,7 @@ const WORKFLOW_MODEL_PAIRS: WorkflowModelPair[] = [
providerId: "validatorFallbackProvider",
modelId: "validatorFallbackModelId",
thinkingId: "validatorFallbackThinkingLevel",
credentialInstanceId: "validatorFallbackCredentialInstanceId",
label: "Reviewer Fallback Model",
help: "Fallback provider and model used when the primary Reviewer model cannot be used.",
},
@@ -90,7 +107,12 @@ function declaredWorkflowModelPairs(settings?: WorkflowSettingDefinition[]): Wor
const model = settingsById.get(pair.modelId);
const thinking = pair.thinkingId ? settingsById.get(pair.thinkingId) : undefined;
return provider?.type === "string" && model?.type === "string" && (!pair.thinkingId || thinking?.type === "enum" || thinking?.type === "string");
});
}).map((pair) => ({
...pair,
credentialInstanceId: settingsById.get(pair.credentialInstanceId ?? "")?.type === "string"
? pair.credentialInstanceId
: undefined,
}));
}
function modelPairValue(values: Record<string, unknown>, pair: WorkflowModelPair): string {
const provider = values[pair.providerId];
@@ -224,11 +246,12 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
const next = { ...current };
delete next[pair.providerId];
delete next[pair.modelId];
if (pair.credentialInstanceId) delete next[pair.credentialInstanceId];
return next;
});
setWorkflowPending((current) => {
if (!value) {
return { ...current, [pair.providerId]: null, [pair.modelId]: null };
return { ...current, [pair.providerId]: null, [pair.modelId]: null, ...(pair.credentialInstanceId ? { [pair.credentialInstanceId]: null } : {}) };
}
const slashIdx = value.indexOf("/");
if (slashIdx <= 0)
@@ -237,9 +260,22 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
...current,
[pair.providerId]: value.slice(0, slashIdx),
[pair.modelId]: value.slice(slashIdx + 1),
// FNXC:ModelDropdown 2026-08-01-10:45: A user-selected provider/model pair must clear its prior credential-instance companion.
...(pair.credentialInstanceId ? { [pair.credentialInstanceId]: null } : {}),
};
});
}, [onWorkflowLanesChange]);
const setWorkflowCredentialInstanceValue = useCallback((pair: WorkflowModelPair, value: string) => {
if (!pair.credentialInstanceId) return;
onWorkflowLanesChange?.();
setWorkflowRejections((current) => {
if (!current[pair.credentialInstanceId as string]) return current;
const next = { ...current };
delete next[pair.credentialInstanceId as string];
return next;
});
setWorkflowPending((current) => ({ ...current, [pair.credentialInstanceId as string]: value || null }));
}, [onWorkflowLanesChange]);
const setWorkflowThinkingValue = useCallback((pair: WorkflowModelPair, value: string) => {
if (!pair.thinkingId)
return;
@@ -256,7 +292,8 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
const resetWorkflowPairValue = useCallback((pair: WorkflowModelPair) => {
setWorkflowPairValue(pair, "");
setWorkflowThinkingValue(pair, "");
}, [setWorkflowPairValue, setWorkflowThinkingValue]);
setWorkflowCredentialInstanceValue(pair, "");
}, [setWorkflowCredentialInstanceValue, setWorkflowPairValue, setWorkflowThinkingValue]);
const saveWorkflowLanes = useCallback(async () => {
if (!projectId || !workflowDirty)
return;
@@ -301,6 +338,16 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
// FNXC:GitHubImportTranslate 2026-07-15-09:30: The import-translate lane is project-scoped (like merger/summarization), so its project override must be editable here — otherwise the lane's projectProviderKey/projectModelKey would be unreachable and only the global lane could ever be set.
// FNXC:SettingsModels 2026-07-15-12:00: Summarization is still project-scoped but is rendered with the AI summarization section below rather than the general Model Lanes list.
const projectModelLanes = modelLanes.filter((lane) => ["default", "merger", "import-translate"].includes(lane.laneId));
const credentialInstanceKeyForLane = (lane: ModelLane): keyof Settings | undefined => PROJECT_LANE_CREDENTIAL_INSTANCE_KEYS[lane.laneId as keyof typeof PROJECT_LANE_CREDENTIAL_INSTANCE_KEYS];
const credentialInstanceValueForLane = (lane: ModelLane): string => {
const key = credentialInstanceKeyForLane(lane);
return key && typeof form[key] === "string" ? form[key] as string : "";
};
const setCredentialInstanceValueForLane = (lane: ModelLane, instanceId: string) => {
const key = credentialInstanceKeyForLane(lane);
if (!key) return;
setForm((current) => ({ ...current, [key]: instanceId || undefined } as SettingsFormState));
};
const summarizationLane = modelLanes.find((lane) => lane.laneId === "summarization");
const getProjectLaneLabel = (lane: ModelLane) => {
if (lane.laneId === "default") {
@@ -343,10 +390,10 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
const mergerFallbackThinkingValue = typeof form.mergerFallbackThinkingLevel === "string"
? form.mergerFallbackThinkingLevel
: "";
const mergerFallbackCustomized = Boolean(mergerFallbackValue || mergerFallbackThinkingValue);
const mergerFallbackCustomized = Boolean(mergerFallbackValue || mergerFallbackThinkingValue || form.mergerFallbackCredentialInstanceId);
const setMergerFallbackValue = (value: string) => {
if (!value) {
setForm((f) => ({ ...f, mergerFallbackProvider: undefined, mergerFallbackModelId: undefined, mergerFallbackThinkingLevel: undefined } as SettingsFormState));
setForm((f) => ({ ...f, mergerFallbackProvider: undefined, mergerFallbackModelId: undefined, mergerFallbackThinkingLevel: undefined, mergerFallbackCredentialInstanceId: undefined } as SettingsFormState));
return;
}
const slashIdx = value.indexOf("/");
@@ -354,13 +401,15 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
...f,
mergerFallbackProvider: value.slice(0, slashIdx),
mergerFallbackModelId: value.slice(slashIdx + 1),
// FNXC:ModelDropdown 2026-08-01-10:45: Explicit model selection clears the prior provider's credential-instance override.
mergerFallbackCredentialInstanceId: undefined,
} as SettingsFormState));
};
const setMergerFallbackThinkingValue = (value: string) => {
setForm((f) => ({ ...f, mergerFallbackThinkingLevel: value || undefined } as SettingsFormState));
};
const resetMergerFallbackValue = () => {
setForm((f) => ({ ...f, mergerFallbackProvider: undefined, mergerFallbackModelId: undefined, mergerFallbackThinkingLevel: undefined } as SettingsFormState));
setForm((f) => ({ ...f, mergerFallbackProvider: undefined, mergerFallbackModelId: undefined, mergerFallbackThinkingLevel: undefined, mergerFallbackCredentialInstanceId: undefined } as SettingsFormState));
};
const renderMergerFallbackLane = () => (
<div className="form-group" data-testid="project-model-lane-merger-fallback">
@@ -375,7 +424,7 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
</div>
<div className="settings-model-lane-control-row">
<div className="settings-model-lane-control-main">
<CustomModelDropdown id="mergerFallbackModel" label="Merger Fallback Model" models={availableModels} value={mergerFallbackValue} onChange={setMergerFallbackValue} placeholder={t("settings.projectModels.useGlobal", "Use global")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} menuWidth="readable" showThinkingLevel={true} thinkingLevel={mergerFallbackThinkingValue} onThinkingLevelChange={setMergerFallbackThinkingValue} defaultThinkingLevel={form.defaultThinkingLevel}/>
<CustomModelDropdown id="mergerFallbackModel" label="Merger Fallback Model" models={availableModels} value={mergerFallbackValue} onChange={setMergerFallbackValue} placeholder={t("settings.projectModels.useGlobal", "Use global")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} menuWidth="readable" showThinkingLevel={true} thinkingLevel={mergerFallbackThinkingValue} onThinkingLevelChange={setMergerFallbackThinkingValue} credentialInstanceId={form.mergerFallbackCredentialInstanceId} onCredentialInstanceChange={(instanceId) => setForm((f) => ({ ...f, mergerFallbackCredentialInstanceId: instanceId || undefined } as SettingsFormState))} defaultThinkingLevel={form.defaultThinkingLevel}/>
</div>
{mergerFallbackCustomized && (<button type="button" className="btn btn-ghost btn-sm" title={t("settings.projectModels.resetToInheritFromGlobal", "Reset to inherit from global")} onClick={resetMergerFallbackValue}>{t("settings.projectModels.reset", " Reset ")}</button>)}
</div>
@@ -387,10 +436,10 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
const titleSummarizerFallbackThinkingValue = typeof form.titleSummarizerFallbackThinkingLevel === "string"
? form.titleSummarizerFallbackThinkingLevel
: "";
const titleSummarizerFallbackCustomized = Boolean(titleSummarizerFallbackValue || titleSummarizerFallbackThinkingValue);
const titleSummarizerFallbackCustomized = Boolean(titleSummarizerFallbackValue || titleSummarizerFallbackThinkingValue || form.titleSummarizerFallbackCredentialInstanceId);
const setTitleSummarizerFallbackValue = (value: string) => {
if (!value) {
setForm((f) => ({ ...f, titleSummarizerFallbackProvider: undefined, titleSummarizerFallbackModelId: undefined, titleSummarizerFallbackThinkingLevel: undefined } as SettingsFormState));
setForm((f) => ({ ...f, titleSummarizerFallbackProvider: undefined, titleSummarizerFallbackModelId: undefined, titleSummarizerFallbackThinkingLevel: undefined, titleSummarizerFallbackCredentialInstanceId: undefined } as SettingsFormState));
return;
}
const slashIdx = value.indexOf("/");
@@ -398,13 +447,15 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
...f,
titleSummarizerFallbackProvider: value.slice(0, slashIdx),
titleSummarizerFallbackModelId: value.slice(slashIdx + 1),
// FNXC:ModelDropdown 2026-08-01-10:45: A newly chosen fallback model cannot inherit a credential instance from its predecessor.
titleSummarizerFallbackCredentialInstanceId: undefined,
} as SettingsFormState));
};
const setTitleSummarizerFallbackThinkingValue = (value: string) => {
setForm((f) => ({ ...f, titleSummarizerFallbackThinkingLevel: value || undefined } as SettingsFormState));
};
const resetTitleSummarizerFallbackValue = () => {
setForm((f) => ({ ...f, titleSummarizerFallbackProvider: undefined, titleSummarizerFallbackModelId: undefined, titleSummarizerFallbackThinkingLevel: undefined } as SettingsFormState));
setForm((f) => ({ ...f, titleSummarizerFallbackProvider: undefined, titleSummarizerFallbackModelId: undefined, titleSummarizerFallbackThinkingLevel: undefined, titleSummarizerFallbackCredentialInstanceId: undefined } as SettingsFormState));
};
/*
* FNXC:SettingsModels 2026-07-15-12:00:
@@ -416,7 +467,7 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
const status = getLaneStatus(lane);
const value = getLaneValue(lane);
const thinkingValue = getLaneThinkingValue(lane);
const isOverridden = status === "overridden" || Boolean(thinkingValue);
const isOverridden = status === "overridden" || Boolean(thinkingValue) || Boolean(credentialInstanceValueForLane(lane));
const laneLabel = getProjectLaneLabel(lane);
return (<div className="form-group" key={lane.laneId}>
{/*
@@ -435,9 +486,9 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
</div>
<div className="settings-model-lane-control-row">
<div className="settings-model-lane-control-main">
<CustomModelDropdown id={`${lane.laneId}Model`} label={laneLabel} models={availableModels} value={value} onChange={(val) => updateLaneValue(lane, val)} placeholder={lane.laneId === "default" ? "Use global default" : "Use global"} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} menuWidth="readable" showThinkingLevel={Boolean(lane.projectThinkingKey)} thinkingLevel={thinkingValue} onThinkingLevelChange={(level) => updateLaneThinkingValue(lane, level)} defaultThinkingLevel={form.defaultThinkingLevel}/>
<CustomModelDropdown id={`${lane.laneId}Model`} label={laneLabel} models={availableModels} value={value} onChange={(val) => { updateLaneValue(lane, val); setCredentialInstanceValueForLane(lane, ""); }} credentialInstanceId={credentialInstanceValueForLane(lane)} onCredentialInstanceChange={(instanceId) => setCredentialInstanceValueForLane(lane, instanceId)} placeholder={lane.laneId === "default" ? "Use global default" : "Use global"} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} menuWidth="readable" showThinkingLevel={Boolean(lane.projectThinkingKey)} thinkingLevel={thinkingValue} onThinkingLevelChange={(level) => updateLaneThinkingValue(lane, level)} defaultThinkingLevel={form.defaultThinkingLevel}/>
</div>
{isOverridden && (<button type="button" className="btn btn-ghost btn-sm" title={t("settings.projectModels.resetToInheritFromGlobal", "Reset to inherit from global")} onClick={() => { resetLaneValue(lane); resetLaneThinkingValue(lane); }} style={{ whiteSpace: "nowrap" }}>{t("settings.projectModels.reset", " Reset ")}</button>)}
{isOverridden && (<button type="button" className="btn btn-ghost btn-sm" title={t("settings.projectModels.resetToInheritFromGlobal", "Reset to inherit from global")} onClick={() => { resetLaneValue(lane); resetLaneThinkingValue(lane); setCredentialInstanceValueForLane(lane, ""); }} style={{ whiteSpace: "nowrap" }}>{t("settings.projectModels.reset", " Reset ")}</button>)}
</div>
</div>);
};
@@ -448,10 +499,10 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
const chatDefaultThinkingValue = typeof form.chatDefaultThinkingLevel === "string"
? form.chatDefaultThinkingLevel
: "";
const chatDefaultCustomized = Boolean(form.chatNewSessionMode || form.chatDefaultKind || form.chatDefaultAgentId || chatDefaultModelValue || chatDefaultThinkingValue);
const chatDefaultCustomized = Boolean(form.chatNewSessionMode || form.chatDefaultKind || form.chatDefaultAgentId || chatDefaultModelValue || chatDefaultThinkingValue || form.chatDefaultCredentialInstanceId);
const setChatDefaultModelValue = (value: string) => {
if (!value) {
setForm((f) => ({ ...f, chatDefaultKind: "model", chatDefaultAgentId: undefined, chatDefaultModelProvider: undefined, chatDefaultModelId: undefined, chatDefaultThinkingLevel: undefined } as SettingsFormState));
setForm((f) => ({ ...f, chatDefaultKind: "model", chatDefaultAgentId: undefined, chatDefaultModelProvider: undefined, chatDefaultModelId: undefined, chatDefaultThinkingLevel: undefined, chatDefaultCredentialInstanceId: undefined } as SettingsFormState));
return;
}
const slashIdx = value.indexOf("/");
@@ -463,13 +514,15 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
chatDefaultAgentId: undefined,
chatDefaultModelProvider: value.slice(0, slashIdx),
chatDefaultModelId: value.slice(slashIdx + 1),
// FNXC:ModelDropdown 2026-08-01-10:45: A fresh chat model selection must not reuse a previous provider's credential instance.
chatDefaultCredentialInstanceId: undefined,
} as SettingsFormState));
};
const setChatDefaultThinkingValue = (value: string) => {
setForm((f) => ({ ...f, chatDefaultThinkingLevel: value || undefined } as SettingsFormState));
};
const resetChatDefaultValue = () => {
setForm((f) => ({ ...f, chatNewSessionMode: undefined, chatDefaultKind: undefined, chatDefaultAgentId: undefined, chatDefaultModelProvider: undefined, chatDefaultModelId: undefined, chatDefaultThinkingLevel: undefined } as SettingsFormState));
setForm((f) => ({ ...f, chatNewSessionMode: undefined, chatDefaultKind: undefined, chatDefaultAgentId: undefined, chatDefaultModelProvider: undefined, chatDefaultModelId: undefined, chatDefaultThinkingLevel: undefined, chatDefaultCredentialInstanceId: undefined } as SettingsFormState));
};
return (<>
@@ -536,7 +589,7 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
<button type="button" className={`chat-new-dialog-mode-btn${chatDefaultKind === "model" ? " chat-new-dialog-mode-btn--active" : ""}`} onClick={() => setForm((f) => ({ ...f, chatDefaultKind: "model", chatDefaultAgentId: undefined } as SettingsFormState))}>
{t("settings.projectModels.chatDefaultKindModel", "Model")}
</button>
<button type="button" className={`chat-new-dialog-mode-btn${chatDefaultKind === "agent" ? " chat-new-dialog-mode-btn--active" : ""}`} onClick={() => setForm((f) => ({ ...f, chatDefaultKind: "agent", chatDefaultModelProvider: undefined, chatDefaultModelId: undefined, chatDefaultThinkingLevel: undefined } as SettingsFormState))}>
<button type="button" className={`chat-new-dialog-mode-btn${chatDefaultKind === "agent" ? " chat-new-dialog-mode-btn--active" : ""}`} onClick={() => setForm((f) => ({ ...f, chatDefaultKind: "agent", chatDefaultModelProvider: undefined, chatDefaultModelId: undefined, chatDefaultThinkingLevel: undefined, chatDefaultCredentialInstanceId: undefined } as SettingsFormState))}>
{t("settings.projectModels.chatDefaultKindAgent", "Agent")}
</button>
</div>
@@ -553,7 +606,7 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
</div>
<div className="settings-model-lane-control-row">
<div className="settings-model-lane-control-main">
<CustomModelDropdown id="chatDefaultModel" label={t("settings.projectModels.chatDefaultModel", "Chat Default Model")} models={availableModels} value={chatDefaultModelValue} onChange={setChatDefaultModelValue} placeholder={t("settings.projectModels.selectChatDefaultModel", "Select a chat default model")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} menuWidth="readable" showThinkingLevel={true} thinkingLevel={chatDefaultThinkingValue} onThinkingLevelChange={setChatDefaultThinkingValue} defaultThinkingLevel={form.defaultThinkingLevel}/>
<CustomModelDropdown id="chatDefaultModel" label={t("settings.projectModels.chatDefaultModel", "Chat Default Model")} models={availableModels} value={chatDefaultModelValue} onChange={setChatDefaultModelValue} credentialInstanceId={typeof form.chatDefaultCredentialInstanceId === "string" ? form.chatDefaultCredentialInstanceId : undefined} onCredentialInstanceChange={(instanceId) => setForm((current) => ({ ...current, chatDefaultCredentialInstanceId: instanceId || undefined } as SettingsFormState))} placeholder={t("settings.projectModels.selectChatDefaultModel", "Select a chat default model")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} menuWidth="readable" showThinkingLevel={true} thinkingLevel={chatDefaultThinkingValue} onThinkingLevelChange={setChatDefaultThinkingValue} defaultThinkingLevel={form.defaultThinkingLevel}/>
</div>
{chatDefaultCustomized && (<button type="button" className="btn btn-ghost btn-sm" title={t("settings.projectModels.chatDefaultReset", "Reset Chat default")} onClick={resetChatDefaultValue}>{t("settings.projectModels.reset", " Reset ")}</button>)}
</div>
@@ -564,7 +617,7 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
</div>
<div className="settings-model-lane-control-row">
<div className="settings-model-lane-control-main">
<select id="chatDefaultAgentId" value={form.chatDefaultAgentId ?? ""} disabled={agentsLoading || agents.length === 0} onChange={(event) => setForm((f) => ({ ...f, chatDefaultKind: "agent", chatDefaultAgentId: event.target.value || undefined, chatDefaultModelProvider: undefined, chatDefaultModelId: undefined, chatDefaultThinkingLevel: undefined } as SettingsFormState))}>
<select id="chatDefaultAgentId" value={form.chatDefaultAgentId ?? ""} disabled={agentsLoading || agents.length === 0} onChange={(event) => setForm((f) => ({ ...f, chatDefaultKind: "agent", chatDefaultAgentId: event.target.value || undefined, chatDefaultModelProvider: undefined, chatDefaultModelId: undefined, chatDefaultThinkingLevel: undefined, chatDefaultCredentialInstanceId: undefined } as SettingsFormState))}>
<option value="">{agentsLoading ? t("settings.projectModels.loadingAgents", "Loading agents…") : t("settings.projectModels.selectChatDefaultAgent", "Select a chat default agent")}</option>
{agents.map((agent) => (<option key={agent.id} value={agent.id}>{agent.name} ({agent.role})</option>))}
</select>
@@ -587,6 +640,8 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
const value = modelPairValue(effectiveWorkflowValues, pair);
const rawThinkingValue = pair.thinkingId ? effectiveWorkflowValues[pair.thinkingId] : undefined;
const thinkingValue: string = typeof rawThinkingValue === "string" ? rawThinkingValue : "";
const rawCredentialInstanceId = pair.credentialInstanceId ? effectiveWorkflowValues[pair.credentialInstanceId] : undefined;
const credentialInstanceId = typeof rawCredentialInstanceId === "string" ? rawCredentialInstanceId : "";
const modelCustomized = Object.prototype.hasOwnProperty.call(workflowPending, pair.providerId)
? workflowPending[pair.providerId] !== null
: Boolean(workflowPayload?.stored && (Object.prototype.hasOwnProperty.call(workflowPayload.stored, pair.providerId)
@@ -596,8 +651,13 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
? workflowPending[pair.thinkingId] !== null
: Boolean(workflowPayload?.stored && Object.prototype.hasOwnProperty.call(workflowPayload.stored, pair.thinkingId)))
: false;
const customized = modelCustomized || thinkingCustomized;
const error = workflowRejections[pair.providerId]?.message ?? workflowRejections[pair.modelId]?.message ?? (pair.thinkingId ? workflowRejections[pair.thinkingId]?.message : undefined);
const credentialInstanceCustomized = pair.credentialInstanceId
? (Object.prototype.hasOwnProperty.call(workflowPending, pair.credentialInstanceId)
? workflowPending[pair.credentialInstanceId] !== null
: Boolean(workflowPayload?.stored && Object.prototype.hasOwnProperty.call(workflowPayload.stored, pair.credentialInstanceId)))
: false;
const customized = modelCustomized || thinkingCustomized || credentialInstanceCustomized;
const error = workflowRejections[pair.providerId]?.message ?? workflowRejections[pair.modelId]?.message ?? (pair.thinkingId ? workflowRejections[pair.thinkingId]?.message : undefined) ?? (pair.credentialInstanceId ? workflowRejections[pair.credentialInstanceId]?.message : undefined);
return (<div className="form-group" key={pair.id} data-testid={`workflow-model-lane-${pair.id}`}>
<div className="settings-model-lane-label-row">
<label htmlFor={`workflow-${pair.id}-model`}>{pair.label}</label>
@@ -609,7 +669,7 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
</div>
<div className="settings-model-lane-control-row">
<div className="settings-model-lane-control-main">
<CustomModelDropdown id={`workflow-${pair.id}-model`} label={pair.label} models={availableModels} value={value} onChange={(next) => setWorkflowPairValue(pair, next)} placeholder={t("settings.projectModels.useWorkflowDefault", "Use workflow default")} defaultOptionLabel="Use workflow default" favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} menuWidth="readable" showThinkingLevel={Boolean(pair.thinkingId)} thinkingLevel={thinkingValue} onThinkingLevelChange={pair.thinkingId ? (level) => setWorkflowThinkingValue(pair, level) : undefined} defaultThinkingLevel={typeof form.defaultThinkingLevel === "string" ? form.defaultThinkingLevel : "off"}/>
<CustomModelDropdown id={`workflow-${pair.id}-model`} label={pair.label} models={availableModels} value={value} onChange={(next) => setWorkflowPairValue(pair, next)} placeholder={t("settings.projectModels.useWorkflowDefault", "Use workflow default")} defaultOptionLabel="Use workflow default" favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} menuWidth="readable" credentialInstanceId={credentialInstanceId} onCredentialInstanceChange={pair.credentialInstanceId ? (instanceId) => setWorkflowCredentialInstanceValue(pair, instanceId) : undefined} showThinkingLevel={Boolean(pair.thinkingId)} thinkingLevel={thinkingValue} onThinkingLevelChange={pair.thinkingId ? (level) => setWorkflowThinkingValue(pair, level) : undefined} defaultThinkingLevel={typeof form.defaultThinkingLevel === "string" ? form.defaultThinkingLevel : "off"}/>
</div>
{customized && (<button type="button" className="btn btn-ghost btn-sm" title={t("settings.projectModels.resetToInheritFromWorkflow", "Reset to inherit from workflow")} onClick={() => resetWorkflowPairValue(pair)} style={{ whiteSpace: "nowrap" }}>{t("settings.projectModels.reset", " Reset ")}</button>)}
</div>
@@ -687,7 +747,7 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
<label htmlFor="preset-executor-model">{t("settings.projectModels.executorModel", "Executor model")}</label>
<CustomModelDropdown id="preset-executor-model" label="Preset executor model" models={availableModels} value={presetDraft.executorProvider && presetDraft.executorModelId ? `${presetDraft.executorProvider}/${presetDraft.executorModelId}` : ""} onChange={(val) => {
if (!val) {
setPresetDraft((current) => current ? { ...current, executorProvider: undefined, executorModelId: undefined } : current);
setPresetDraft((current) => current ? { ...current, executorProvider: undefined, executorModelId: undefined, executorCredentialInstanceId: undefined } : current);
return;
}
const slashIdx = val.indexOf("/");
@@ -695,14 +755,15 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
...current,
executorProvider: val.slice(0, slashIdx),
executorModelId: val.slice(slashIdx + 1),
executorCredentialInstanceId: undefined,
} : current);
}} placeholder={t("settings.projectModels.useDefault", "Use default")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} menuWidth="readable"/>
}} placeholder={t("settings.projectModels.useDefault", "Use default")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} menuWidth="readable" credentialInstanceId={presetDraft.executorCredentialInstanceId} onCredentialInstanceChange={(instanceId) => setPresetDraft((current) => current ? { ...current, ...(instanceId ? { executorCredentialInstanceId: instanceId } : { executorCredentialInstanceId: undefined }) } : current)}/>
</div>
<div className="form-group">
<label htmlFor="preset-validator-model">{t("settings.projectModels.reviewerModel", "Reviewer model")}</label>
<CustomModelDropdown id="preset-validator-model" label="Preset reviewer model" models={availableModels} value={presetDraft.validatorProvider && presetDraft.validatorModelId ? `${presetDraft.validatorProvider}/${presetDraft.validatorModelId}` : ""} onChange={(val) => {
if (!val) {
setPresetDraft((current) => current ? { ...current, validatorProvider: undefined, validatorModelId: undefined } : current);
setPresetDraft((current) => current ? { ...current, validatorProvider: undefined, validatorModelId: undefined, validatorCredentialInstanceId: undefined } : current);
return;
}
const slashIdx = val.indexOf("/");
@@ -710,8 +771,9 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
...current,
validatorProvider: val.slice(0, slashIdx),
validatorModelId: val.slice(slashIdx + 1),
validatorCredentialInstanceId: undefined,
} : current);
}} placeholder={t("settings.projectModels.useDefault", "Use default")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} menuWidth="readable"/>
}} placeholder={t("settings.projectModels.useDefault", "Use default")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} menuWidth="readable" credentialInstanceId={presetDraft.validatorCredentialInstanceId} onCredentialInstanceChange={(instanceId) => setPresetDraft((current) => current ? { ...current, ...(instanceId ? { validatorCredentialInstanceId: instanceId } : { validatorCredentialInstanceId: undefined }) } : current)}/>
</div>
</>)}
</div>
@@ -802,7 +864,7 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
</div>
<div className="settings-model-lane-control-row">
<div className="settings-model-lane-control-main">
<CustomModelDropdown id="titleSummarizerFallbackModel" label="Title Summarizer Fallback Model" models={availableModels} value={titleSummarizerFallbackValue} onChange={setTitleSummarizerFallbackValue} placeholder={t("settings.projectModels.useGlobal", "Use global")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} menuWidth="readable" showThinkingLevel={true} thinkingLevel={titleSummarizerFallbackThinkingValue} onThinkingLevelChange={setTitleSummarizerFallbackThinkingValue} defaultThinkingLevel={form.defaultThinkingLevel}/>
<CustomModelDropdown id="titleSummarizerFallbackModel" label="Title Summarizer Fallback Model" models={availableModels} value={titleSummarizerFallbackValue} onChange={setTitleSummarizerFallbackValue} placeholder={t("settings.projectModels.useGlobal", "Use global")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} menuWidth="readable" showThinkingLevel={true} thinkingLevel={titleSummarizerFallbackThinkingValue} onThinkingLevelChange={setTitleSummarizerFallbackThinkingValue} credentialInstanceId={form.titleSummarizerFallbackCredentialInstanceId} onCredentialInstanceChange={(instanceId) => setForm((f) => ({ ...f, titleSummarizerFallbackCredentialInstanceId: instanceId || undefined } as SettingsFormState))} defaultThinkingLevel={form.defaultThinkingLevel}/>
</div>
{titleSummarizerFallbackCustomized && (<button type="button" className="btn btn-ghost btn-sm" title={t("settings.projectModels.resetToInheritFromGlobal", "Reset to inherit from global")} onClick={resetTitleSummarizerFallbackValue} style={{ whiteSpace: "nowrap" }}>{t("settings.projectModels.reset", " Reset ")}</button>)}
</div>

View File

@@ -1,6 +1,7 @@
// @vitest-environment jsdom
import { useState } from "react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import * as jestDomMatchers from "@testing-library/jest-dom/matchers";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ProjectModelsSection } from "../ProjectModelsSection";
@@ -25,7 +26,7 @@ vi.mock("../../../../api", async (importOriginal) => {
});
vi.mock("../../../CustomModelDropdown", () => ({
CustomModelDropdown: ({ id, label, value, onChange, showThinkingLevel, thinkingLevel, onThinkingLevelChange, defaultThinkingLevel }: {
CustomModelDropdown: ({ id, label, value, onChange, showThinkingLevel, thinkingLevel, onThinkingLevelChange, defaultThinkingLevel, credentialInstanceId, onCredentialInstanceChange }: {
id?: string;
label: string;
value?: string;
@@ -34,10 +35,13 @@ vi.mock("../../../CustomModelDropdown", () => ({
thinkingLevel?: string;
onThinkingLevelChange?: (value: string) => void;
defaultThinkingLevel?: string;
credentialInstanceId?: string;
onCredentialInstanceChange?: (value: string) => void;
}) => (
<div data-testid={`mock-model-host-${id ?? label}`} data-value={value ?? ""} data-default-thinking={defaultThinkingLevel ?? ""}>
<button type="button" data-testid={`mock-model-dropdown-${id ?? label}`} onClick={() => onChange?.("anthropic/claude-sonnet-4-5")}>{label}</button>
{showThinkingLevel ? <button type="button" data-testid={`mock-thinking-${id ?? label}`} onClick={() => onThinkingLevelChange?.(thinkingLevel ? "" : "high")}>thinking:{thinkingLevel || "inherit"}</button> : null}
{onCredentialInstanceChange ? <><button type="button" data-testid={`mock-instance-${id ?? label}`} onClick={() => onCredentialInstanceChange("backup")}>instance:{credentialInstanceId || "default"}</button><button type="button" data-testid={`mock-instance-default-${id ?? label}`} onClick={() => onCredentialInstanceChange("")}>default instance</button></> : null}
</div>
),
}));
@@ -189,4 +193,31 @@ describe("ProjectModelsSection Chat default settings", () => {
chatDefaultThinkingLevel: undefined,
});
});
it("round-trips a project lane credential instance and clears it with Default", async () => {
const user = userEvent.setup();
const lane = {
laneId: "merger",
label: "Merger Model",
globalProviderKey: "mergerGlobalProvider",
globalModelKey: "mergerGlobalModelId",
projectProviderKey: "mergerProvider",
projectModelKey: "mergerModelId",
helperText: "",
fallbackOrder: "",
} as ProjectModelsSectionModelProps["modelLanes"][number];
const laneModels = { ...models, modelLanes: [lane] };
let latestForm: SettingsFormState = { mergerProvider: "anthropic", mergerModelId: "claude-sonnet-4-5" } as SettingsFormState;
function Host() {
const [form, setForm] = useState(latestForm);
latestForm = form;
return <ProjectModelsSection form={form} setForm={setForm} models={laneModels} projectId="project-1" addToast={vi.fn()} />;
}
render(<Host />);
await user.click(screen.getByTestId("mock-instance-mergerModel"));
expect(latestForm.mergerCredentialInstanceId).toBe("backup");
await user.click(screen.getByTestId("mock-instance-default-mergerModel"));
expect(latestForm.mergerCredentialInstanceId).toBeUndefined();
});
});

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { updateGlobalSettings, type ModelInfo } from "../api";
import { updateGlobalSettings, type ModelInfo, type ProviderCredentialInstanceSummary } from "../api";
import { useModelsCache } from "./useModelsCache";
/**
@@ -9,6 +9,7 @@ export interface UseFavoritesResult {
availableModels: ModelInfo[];
favoriteProviders: string[];
favoriteModels: string[];
providerInstances: Record<string, { instances: ProviderCredentialInstanceSummary[] }>;
toggleFavoriteProvider: (provider: string) => Promise<void>;
toggleFavoriteModel: (modelId: string) => Promise<void>;
}
@@ -17,7 +18,7 @@ export interface UseFavoritesResult {
* Loads model catalog + favorites and exposes optimistic favorite toggles.
*/
export function useFavorites(): UseFavoritesResult {
const { models, favoriteProviders: cachedFavoriteProviders, favoriteModels: cachedFavoriteModels, refresh } = useModelsCache();
const { models, favoriteProviders: cachedFavoriteProviders, favoriteModels: cachedFavoriteModels, providerInstances, refresh } = useModelsCache();
const [availableModels, setAvailableModels] = useState<ModelInfo[]>(models);
const [favoriteProviders, setFavoriteProviders] = useState<string[]>(cachedFavoriteProviders);
const [favoriteModels, setFavoriteModels] = useState<string[]>(cachedFavoriteModels);
@@ -96,6 +97,7 @@ export function useFavorites(): UseFavoritesResult {
availableModels,
favoriteProviders,
favoriteModels,
providerInstances,
toggleFavoriteProvider,
toggleFavoriteModel,
};

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchModels, type ModelInfo, type ModelsResponse } from "../api";
import { fetchModels, type ModelInfo, type ModelsResponse, type ProviderCredentialInstanceSummary } from "../api";
import { clearCache, readCache, SWR_CACHE_KEYS, SWR_DEFAULT_MAX_AGE_MS, writeCache } from "../utils/swrCache";
interface ModelsCacheState {
@@ -8,6 +8,7 @@ interface ModelsCacheState {
favoriteModels: string[];
defaultProvider: string | null;
defaultModelId: string | null;
providerInstances: Record<string, { instances: ProviderCredentialInstanceSummary[] }>;
}
export interface UseModelsCacheResult extends ModelsCacheState {
@@ -21,6 +22,7 @@ const EMPTY_MODELS_STATE: ModelsCacheState = {
favoriteModels: [],
defaultProvider: null,
defaultModelId: null,
providerInstances: {},
};
let inflight: Promise<ModelsResponse> | null = null;
@@ -41,6 +43,7 @@ function toModelsCacheState(response: ModelsResponse | null | undefined): Models
favoriteModels: response.favoriteModels ?? [],
defaultProvider: response.defaultProvider ?? null,
defaultModelId: response.defaultModelId ?? null,
providerInstances: response.providerInstances ?? {},
};
}

View File

@@ -3341,6 +3341,8 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
planningModelId,
nodeId,
thinkingLevel,
credentialInstanceId,
validatorCredentialInstanceId,
} = req.body;
// Validate taskIds
@@ -3360,13 +3362,18 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
const hasPlanningModel = planningModelProvider !== undefined || planningModelId !== undefined;
const hasNodeId = nodeId !== undefined;
const hasThinkingLevel = thinkingLevel !== undefined;
if (!hasExecutorModel && !hasValidatorModel && !hasPlanningModel && !hasNodeId && !hasThinkingLevel) {
const hasCredentialInstance = credentialInstanceId !== undefined || validatorCredentialInstanceId !== undefined;
if (!hasExecutorModel && !hasValidatorModel && !hasPlanningModel && !hasNodeId && !hasThinkingLevel && !hasCredentialInstance) {
throw badRequest("At least one model field, thinkingLevel, or nodeId must be provided");
}
if (nodeId !== undefined && nodeId !== null && typeof nodeId !== "string") {
throw badRequest("nodeId must be a string, null, or undefined");
}
if ((credentialInstanceId !== undefined && credentialInstanceId !== null && typeof credentialInstanceId !== "string")
|| (validatorCredentialInstanceId !== undefined && validatorCredentialInstanceId !== null && typeof validatorCredentialInstanceId !== "string")) {
throw badRequest("credential instance IDs must be strings, null, or undefined");
}
if (thinkingLevel !== undefined && thinkingLevel !== null && (typeof thinkingLevel !== "string" || !THINKING_LEVELS.includes(thinkingLevel as ThinkingLevel))) {
throw badRequest(`thinkingLevel must be one of ${THINKING_LEVELS.join(", ")}, null, or undefined`);
}
@@ -3430,6 +3437,8 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
planningModelId?: string | null;
nodeId?: string | null;
thinkingLevel?: ThinkingLevel | null;
credentialInstanceId?: string | null;
validatorCredentialInstanceId?: string | null;
} = {};
if (validatedExecutor.provider !== undefined) {
updates.modelProvider = validatedExecutor.provider;
@@ -3459,6 +3468,12 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
if (thinkingLevel !== undefined) {
updates.thinkingLevel = thinkingLevel as ThinkingLevel | null;
}
/*
FNXC:TaskBulkModels 2026-08-01-10:34:
Bulk model editing must persist explicit credential-instance choices and clear them on Default. Omitting unchanged lanes preserves the no-change sentinel rather than rewriting task overrides.
*/
if (credentialInstanceId !== undefined) updates.credentialInstanceId = credentialInstanceId;
if (validatorCredentialInstanceId !== undefined) updates.validatorCredentialInstanceId = validatorCredentialInstanceId;
// Update all tasks in parallel
const updatePromises = taskIds.map(async (taskId) => {