diff --git a/.changeset/fn-8666-credential-instance-picker.md b/.changeset/fn-8666-credential-instance-picker.md new file mode 100644 index 0000000000..d80eb47281 --- /dev/null +++ b/.changeset/fn-8666-credential-instance-picker.md @@ -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. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 91be86f14d..d42feb2016 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -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. diff --git a/docs/workflow-steps.md b/docs/workflow-steps.md index ecebcdb1cf..6dce019639 100644 --- a/docs/workflow-steps.md +++ b/docs/workflow-steps.md @@ -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 diff --git a/packages/dashboard/app/api.ts b/packages/dashboard/app/api.ts index 50757b9cb3..01e1e3a782 100644 --- a/packages/dashboard/app/api.ts +++ b/packages/dashboard/app/api.ts @@ -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"; diff --git a/packages/dashboard/app/api/models-usage.ts b/packages/dashboard/app/api/models-usage.ts index 73a4f73165..7638b30209 100644 --- a/packages/dashboard/app/api/models-usage.ts +++ b/packages/dashboard/app/api/models-usage.ts @@ -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; defaultModelId?: string; resolvedPlanningProvider?: string; resolvedPlanningModelId?: string; } /** Fetch available AI models from the model registry along with favoriteProviders */ -export function fetchModels(): Promise { - return api("/models"); +export async function fetchModels(): Promise { + const response = await api("/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 --- diff --git a/packages/dashboard/app/api/tasks.ts b/packages/dashboard/app/api/tasks.ts index a4139b3251..6e39c5ef9d 100644 --- a/packages/dashboard/app/api/tasks.ts +++ b/packages/dashboard/app/api/tasks.ts @@ -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 } : {}), }), }); } diff --git a/packages/dashboard/app/components/CustomModelDropdown.css b/packages/dashboard/app/components/CustomModelDropdown.css index 6efdacfd8f..9726ce14a3 100644 --- a/packages/dashboard/app/components/CustomModelDropdown.css +++ b/packages/dashboard/app/components/CustomModelDropdown.css @@ -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 onCredentialInstanceChange?.(e.target.value)} + disabled={disabled || !onCredentialInstanceChange} + aria-label={t("models.labels.credentialInstance", "Credential instance")} + > + + {instanceOptions.map((instance) => ( + + ))} + + + )} + {shouldShowThinking && (
e.stopPropagation()} onClick={(e) => e.stopPropagation()}>
diff --git a/packages/dashboard/app/components/InlineCreateCard.tsx b/packages/dashboard/app/components/InlineCreateCard.tsx index 03c83f994d..adf316ed79 100644 --- a/packages/dashboard/app/components/InlineCreateCard.tsx +++ b/packages/dashboard/app/components/InlineCreateCard.tsx @@ -118,12 +118,16 @@ export function InlineCreateCard({ const [settings, setSettings] = useState(null); const [selectedPresetId, setSelectedPresetId] = useState(undefined); const [executorProvider, setExecutorProvider] = useState(undefined); + const [credentialInstanceId, setCredentialInstanceId] = useState(undefined); const [executorModelId, setExecutorModelId] = useState(undefined); const [validatorProvider, setValidatorProvider] = useState(undefined); + const [validatorCredentialInstanceId, setValidatorCredentialInstanceId] = useState(undefined); const [validatorModelId, setValidatorModelId] = useState(undefined); const [planningProvider, setPlanningProvider] = useState(undefined); + const [planningCredentialInstanceId, setPlanningCredentialInstanceId] = useState(undefined); const [planningModelId, setPlanningModelId] = useState(undefined); const [mergerProvider, setMergerProvider] = useState(undefined); + const [mergerCredentialInstanceId, setMergerCredentialInstanceId] = useState(undefined); const [mergerModelId, setMergerModelId] = useState(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(""); @@ -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} diff --git a/packages/dashboard/app/components/ListView.tsx b/packages/dashboard/app/components/ListView.tsx index 631206302f..d7fd54410a 100644 --- a/packages/dashboard/app/components/ListView.tsx +++ b/packages/dashboard/app/components/ListView.tsx @@ -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("__no_change__"); + const [credentialInstanceId, setCredentialInstanceId] = useState("__no_change__"); const [validatorModel, setValidatorModel] = useState("__no_change__"); + const [validatorCredentialInstanceId, setValidatorCredentialInstanceId] = useState("__no_change__"); const [bulkThinkingLevel, setBulkThinkingLevel] = useState("__no_change__"); const [nodeOverride, setNodeOverride] = useState("__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({ { 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({ { 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")} diff --git a/packages/dashboard/app/components/ModelSelectionModal.tsx b/packages/dashboard/app/components/ModelSelectionModal.tsx index 0ce44fef0e..11373db127 100644 --- a/packages/dashboard/app/components/ModelSelectionModal.tsx +++ b/packages/dashboard/app/components/ModelSelectionModal.tsx @@ -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"} /> @@ -318,6 +337,8 @@ export function ModelSelectionModal({ onToggleModelFavorite={onToggleModelFavorite} thinkingLevel={thinkingLevel} onThinkingLevelChange={onThinkingLevelChange} + credentialInstanceId={credentialInstanceId} + onCredentialInstanceChange={onCredentialInstanceChange} defaultThinkingLevel={defaultThinkingLevel ?? "off"} /> @@ -326,7 +347,7 @@ export function ModelSelectionModal({ {onMergerChange ?
{getModelBadgeLabel(models, mergerValue, t)} - +
: null}
@@ -353,6 +374,8 @@ export function ModelSelectionModal({ onToggleModelFavorite={onToggleModelFavorite} thinkingLevel={validatorThinkingLevel} onThinkingLevelChange={onValidatorThinkingLevelChange} + credentialInstanceId={validatorCredentialInstanceId} + onCredentialInstanceChange={onValidatorCredentialInstanceChange} defaultThinkingLevel={defaultThinkingLevel ?? "off"} />
diff --git a/packages/dashboard/app/components/ModelSelectorTab.tsx b/packages/dashboard/app/components/ModelSelectorTab.tsx index 2e8d6afd40..485b948549 100644 --- a/packages/dashboard/app/components/ModelSelectorTab.tsx +++ b/packages/dashboard/app/components/ModelSelectorTab.tsx @@ -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
{mergerUsingDefault ? {t("models.states.usingDefault", "Using default")}{effectiveMerger.provider && effectiveMerger.modelId ? ` (${effectiveMerger.provider}/${effectiveMerger.modelId})` : ""} : {savedMerger.provider && }{savedMerger.provider}/{savedMerger.modelId}}
- + 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"} /> {t("models.descriptions.merger", "The AI model used to merge this task.")} diff --git a/packages/dashboard/app/components/NewTaskModal.tsx b/packages/dashboard/app/components/NewTaskModal.tsx index a36636fe67..cc07a13a5e 100644 --- a/packages/dashboard/app/components/NewTaskModal.tsx +++ b/packages/dashboard/app/components/NewTaskModal.tsx @@ -358,8 +358,11 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, const [isSubmitting, setIsSubmitting] = useState(false); const [duplicateMatches, setDuplicateMatches] = useState(null); const [executorModel, setExecutorModel] = useState(""); + const [credentialInstanceId, setCredentialInstanceId] = useState(undefined); const [validatorModel, setValidatorModel] = useState(""); + const [validatorCredentialInstanceId, setValidatorCredentialInstanceId] = useState(undefined); const [planningModel, setPlanningModel] = useState(""); + const [planningCredentialInstanceId, setPlanningCredentialInstanceId] = useState(undefined); const [thinkingLevel, setThinkingLevel] = useState(""); // 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(""); @@ -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} diff --git a/packages/dashboard/app/components/QuickEntryBox.tsx b/packages/dashboard/app/components/QuickEntryBox.tsx index a84dd9e835..414b3bb544 100644 --- a/packages/dashboard/app/components/QuickEntryBox.tsx +++ b/packages/dashboard/app/components/QuickEntryBox.tsx @@ -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(undefined); const [executorModelId, setExecutorModelId] = useState(undefined); + const [credentialInstanceId, setCredentialInstanceId] = useState(undefined); const [validatorProvider, setValidatorProvider] = useState(undefined); const [validatorModelId, setValidatorModelId] = useState(undefined); + const [validatorCredentialInstanceId, setValidatorCredentialInstanceId] = useState(undefined); const [planningProvider, setPlanningProvider] = useState(undefined); const [planningModelId, setPlanningModelId] = useState(undefined); + const [planningCredentialInstanceId, setPlanningCredentialInstanceId] = useState(undefined); const [mergerProvider, setMergerProvider] = useState(undefined); const [mergerModelId, setMergerModelId] = useState(undefined); + const [mergerCredentialInstanceId, setMergerCredentialInstanceId] = useState(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(""); const [validatorThinkingLevel, setValidatorThinkingLevel] = useState(""); @@ -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 && (
diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index d5137a4e65..6c05240d40 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -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) => { diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index fe0a5c09e6..3fcaf8f2c9 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -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(undefined); const [editValidatorModel, setEditValidatorModel] = useState(""); + const [editValidatorCredentialInstanceId, setEditValidatorCredentialInstanceId] = useState(undefined); const [editPlanningModel, setEditPlanningModel] = useState(""); + const [editPlanningCredentialInstanceId, setEditPlanningCredentialInstanceId] = useState(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} diff --git a/packages/dashboard/app/components/TaskForm.tsx b/packages/dashboard/app/components/TaskForm.tsx index 27d25debb2..a4c1eeae0a 100644 --- a/packages/dashboard/app/components/TaskForm.tsx +++ b/packages/dashboard/app/components/TaskForm.tsx @@ -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} />
@@ -1591,6 +1605,8 @@ export function TaskForm({ onToggleFavorite={handleToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={handleToggleModelFavorite} + credentialInstanceId={validatorCredentialInstanceId} + onCredentialInstanceChange={onValidatorCredentialInstanceIdChange} />
{onPlanningModelChange && ( @@ -1611,6 +1627,8 @@ export function TaskForm({ onToggleFavorite={handleToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={handleToggleModelFavorite} + credentialInstanceId={planningCredentialInstanceId} + onCredentialInstanceChange={onPlanningCredentialInstanceIdChange} /> )} diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.tsx b/packages/dashboard/app/components/WorkflowNodeEditor.tsx index 601cdcfbe4..b89b81e983 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.tsx +++ b/packages/dashboard/app/components/WorkflowNodeEditor.tsx @@ -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. diff --git a/packages/dashboard/app/components/WorkflowSettingsPanel.tsx b/packages/dashboard/app/components/WorkflowSettingsPanel.tsx index 336aed36da..474965b108 100644 --- a/packages/dashboard/app/components/WorkflowSettingsPanel.tsx +++ b/packages/dashboard/app/components/WorkflowSettingsPanel.tsx @@ -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} diff --git a/packages/dashboard/app/components/__tests__/CustomModelDropdown.credential-instance.test.tsx b/packages/dashboard/app/components/__tests__/CustomModelDropdown.credential-instance.test.tsx new file mode 100644 index 0000000000..7aefca99a4 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/CustomModelDropdown.credential-instance.test.tsx @@ -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: () => , +})); + +const models = [{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 }]; + +async function openDropdown(props: Partial>) { + const user = userEvent.setup(); + render(); + 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( + , + ); + await user.click(screen.getByRole("button", { name: "Model" })); + expect(screen.getByTestId("custom-model-dropdown-credential-instance")).toBeTruthy(); + rerender( + , + ); + expect(screen.queryByTestId("custom-model-dropdown-credential-instance")).toBeNull(); + await user.keyboard("{Escape}"); + expect(screen.queryByTestId("model-combobox-portal")).toBeNull(); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/WorkflowSettingsPanel.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowSettingsPanel.test.tsx index 270d27d51d..a026ec45e2 100644 --- a/packages/dashboard/app/components/__tests__/WorkflowSettingsPanel.test.tsx +++ b/packages/dashboard/app/components/__tests__/WorkflowSettingsPanel.test.tsx @@ -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", ); }); diff --git a/packages/dashboard/app/components/effective-model-resolution.ts b/packages/dashboard/app/components/effective-model-resolution.ts index 30e527d24b..abd04dc80e 100644 --- a/packages/dashboard/app/components/effective-model-resolution.ts +++ b/packages/dashboard/app/components/effective-model-resolution.ts @@ -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) { diff --git a/packages/dashboard/app/components/settings/sections/GlobalModelsSection.tsx b/packages/dashboard/app/components/settings/sections/GlobalModelsSection.tsx index 3bc5630884..ba9043bdd3 100644 --- a/packages/dashboard/app/components/settings/sections/GlobalModelsSection.tsx +++ b/packages/dashboard/app/components/settings/sections/GlobalModelsSection.tsx @@ -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> = { + 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 { 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}/>
@@ -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. */} { 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 (
{/* 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. */}
@@ -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}/>
); })} )} diff --git a/packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx b/packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx index 3ad9d66ab8..b4ceba85a2 100644 --- a/packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx +++ b/packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx @@ -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>; /* 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, 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 = () => (
@@ -375,7 +424,7 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
- + setForm((f) => ({ ...f, mergerFallbackCredentialInstanceId: instanceId || undefined } as SettingsFormState))} defaultThinkingLevel={form.defaultThinkingLevel}/>
{mergerFallbackCustomized && ()}
@@ -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 (
{/* @@ -435,9 +486,9 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
- 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}/> + { 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}/>
- {isOverridden && ()} + {isOverridden && ()}
); }; @@ -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 -
@@ -553,7 +606,7 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
- + 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}/>
{chatDefaultCustomized && ()}
@@ -564,7 +617,7 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
- setForm((f) => ({ ...f, chatDefaultKind: "agent", chatDefaultAgentId: event.target.value || undefined, chatDefaultModelProvider: undefined, chatDefaultModelId: undefined, chatDefaultThinkingLevel: undefined, chatDefaultCredentialInstanceId: undefined } as SettingsFormState))}> {agents.map((agent) => ())} @@ -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 (
@@ -609,7 +669,7 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
- 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"}/> + 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"}/>
{customized && ()}
@@ -687,7 +747,7 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW { 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)}/>
{ 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)}/>
)}
@@ -802,7 +864,7 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
- + setForm((f) => ({ ...f, titleSummarizerFallbackCredentialInstanceId: instanceId || undefined } as SettingsFormState))} defaultThinkingLevel={form.defaultThinkingLevel}/>
{titleSummarizerFallbackCustomized && ()}
diff --git a/packages/dashboard/app/components/settings/sections/__tests__/ProjectModelsSection.chatDefault.test.tsx b/packages/dashboard/app/components/settings/sections/__tests__/ProjectModelsSection.chatDefault.test.tsx index 6097b01956..76d33222c4 100644 --- a/packages/dashboard/app/components/settings/sections/__tests__/ProjectModelsSection.chatDefault.test.tsx +++ b/packages/dashboard/app/components/settings/sections/__tests__/ProjectModelsSection.chatDefault.test.tsx @@ -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; }) => (
{showThinkingLevel ? : null} + {onCredentialInstanceChange ? <> : null}
), })); @@ -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 ; + } + render(); + + 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(); + }); + }); diff --git a/packages/dashboard/app/hooks/useFavorites.ts b/packages/dashboard/app/hooks/useFavorites.ts index 3867bef2bf..45abbbdae6 100644 --- a/packages/dashboard/app/hooks/useFavorites.ts +++ b/packages/dashboard/app/hooks/useFavorites.ts @@ -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; toggleFavoriteProvider: (provider: string) => Promise; toggleFavoriteModel: (modelId: string) => Promise; } @@ -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(models); const [favoriteProviders, setFavoriteProviders] = useState(cachedFavoriteProviders); const [favoriteModels, setFavoriteModels] = useState(cachedFavoriteModels); @@ -96,6 +97,7 @@ export function useFavorites(): UseFavoritesResult { availableModels, favoriteProviders, favoriteModels, + providerInstances, toggleFavoriteProvider, toggleFavoriteModel, }; diff --git a/packages/dashboard/app/hooks/useModelsCache.ts b/packages/dashboard/app/hooks/useModelsCache.ts index 3adba54ca7..2200d437e7 100644 --- a/packages/dashboard/app/hooks/useModelsCache.ts +++ b/packages/dashboard/app/hooks/useModelsCache.ts @@ -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; } export interface UseModelsCacheResult extends ModelsCacheState { @@ -21,6 +22,7 @@ const EMPTY_MODELS_STATE: ModelsCacheState = { favoriteModels: [], defaultProvider: null, defaultModelId: null, + providerInstances: {}, }; let inflight: Promise | 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 ?? {}, }; } diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index cc5540dde1..63aaef34d9 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -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) => {