Files
fusion/packages/dashboard/app/components/effective-model-resolution.ts
gsxdsm 0e8f6769ba FN-8666: add credential instance pickers
Add credential-instance selection across dashboard model configuration surfaces.

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

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

Fusion-Task-Id: FN-8666

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

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-08-01 04:03:26 -07:00

208 lines
9.1 KiB
TypeScript

import type { Agent, AgentLogEntry, ResolvedModelSelection, Settings, Task, TaskDetail } from "@fusion/core";
import { isWipColumnRole } from "../utils/columnRoles";
// FNXC:WorkflowLifecycleColumns 2026-07-30-11:50: these are AGENT ROLE comparisons, not
// column guards — the planner LANE keeps the name `triage`; U11 removed only the COLUMN.
import { PLANNER_AGENT_ROLE, resolveTaskExecutionModel, resolveTaskPlanningModel, resolveTaskValidatorModel } from "@fusion/core";
import { ACTIVE_STATUSES } from "../utils/taskActivity";
export type ModelSelection = ResolvedModelSelection;
export { ACTIVE_STATUSES };
const STRING_OBJECT_TAG = "[object String]";
function isStringValue(value: unknown): value is string {
return Object.prototype.toString.call(value) === STRING_OBJECT_TAG;
}
/*
FNXC:ModelResolution 2026-06-25-00:00:
FN-7040 requires the Chat tab, Agent Log header, and Workflow tab Model settings to share one effective model resolver so runtime log markers, active assigned-agent runtime models, task overrides, and settings fallbacks never diverge between task-detail surfaces.
FNXC:TaskLogModelThinking 2026-07-01-00:00:
Runtime "using model" markers may append parenthesized diagnostics such as thinking effort, workflow-step overrides, or fallback reasons. Dashboard model resolution strips those suffix annotations while preserving legacy exact markers so provider icons and effective-model headers continue to resolve from the same row operators read in Activity and Raw Logs.
FNXC:PlanningModelMarker 2026-07-21-12:00:
New planning sessions identify the operator-facing lane as Planning, while historical rows retain Triage. Treat both prefixes as one planning lane so stored logs continue to resolve provider icons and effective-model headers.
*/
const MODEL_MARKER_PATTERN = /^(Planning|Triage|Executor|Reviewer) using model: ([^/\s]+)\/(.+?)(?:\s+\([^)]*\))*$/;
/*
FNXC:TaskLogModelThinking 2026-07-15-11:20:
Engine lanes now write standalone messages (including the "using model" markers) as `status` rather than `text`, so complete messages are never glued together like streamed deltas. Model resolution must accept BOTH: `status` for markers written after that change, `text` for the rows already persisted in every existing task's log. Dropping `text` here would silently blank the provider icons and effective-model headers on historical tasks.
*/
function isEngineMarkerEntryType(type: AgentLogEntry["type"]): boolean {
return type === "status" || type === "text";
}
export function parseRuntimeModelMarker(text: string, role: "Planning" | "Triage" | "Executor" | "Reviewer"): { provider: string; modelId: string } | null {
const match = text.match(MODEL_MARKER_PATTERN);
const isPlanningRole = role === "Planning" || role === "Triage";
const matchesRole = isPlanningRole
? match?.[1] === "Planning" || match?.[1] === "Triage"
: match?.[1] === role;
if (!match || !matchesRole) return null;
return { provider: match[2], modelId: match[3] };
}
export function extractExecutorModelFromLog(entries: AgentLogEntry[]): { provider: string; modelId: string } | null {
let result: { provider: string; modelId: string } | null = null;
entries.forEach((entry) => {
if (entry.agent !== "executor" || !isEngineMarkerEntryType(entry.type)) return;
const match = parseRuntimeModelMarker(entry.text, "Executor");
if (match) {
result = match;
}
});
return result;
}
export function extractReviewerModelFromLog(entries: AgentLogEntry[]): { provider: string; modelId: string } | null {
let result: { provider: string; modelId: string } | null = null;
entries.forEach((entry) => {
if (entry.agent !== "reviewer" || !isEngineMarkerEntryType(entry.type)) return;
const match = parseRuntimeModelMarker(entry.text, "Reviewer");
if (match) {
result = match;
}
});
return result;
}
export function extractAssignedRuntimeModel(agent: Agent | null | undefined): ModelSelection {
const runtimeConfig = (agent?.runtimeConfig ?? undefined) as Record<string, unknown> | undefined;
const model = isStringValue(runtimeConfig?.model) ? runtimeConfig.model.trim() : "";
if (model) {
const slashIdx = model.indexOf("/");
if (slashIdx > 0 && slashIdx < model.length - 1) {
return {
provider: model.slice(0, slashIdx),
modelId: model.slice(slashIdx + 1),
};
}
}
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 } : {}),
};
}
/**
* Resolve the effective executor model following the dashboard display resolution order:
* 1. Runtime executor model from agent log marker
* 2. Assigned agent runtime model (active runs only)
* 3. Per-task modelProvider/modelId override
* 4. Project/global execution lane fallback
*/
export function resolveEffectiveExecutor(
task: Task | TaskDetail,
logEntries: AgentLogEntry[],
assignedAgent: Agent | null,
settings?: Settings,
columnFlags?: Parameters<typeof isWipColumnRole>[0],
): ModelSelection {
const fromLog = extractExecutorModelFromLog(logEntries);
if (fromLog) return fromLog;
/*
FNXC:WorkflowResolvedColumns 2026-07-30-21:30 (batch-dashboard-app):
WIP role, resolved; `columnFlags` omitted -> the legacy id.
This decides whether the ASSIGNED AGENT's runtime model is the effective one — true only while the
card is actually being worked. Keyed on the literal, a card executing in a renamed wip lane fell
through to the configured default, so the dashboard displayed a different model than the one the
running agent was using. Wrong in the quietest possible way: a plausible model name, for the whole
duration of the run.
*/
if (ACTIVE_STATUSES.has(task.status ?? "") || isWipColumnRole(columnFlags, task.column)) {
const assignedModel = extractAssignedRuntimeModel(assignedAgent);
if (assignedModel.provider && assignedModel.modelId) {
return assignedModel;
}
}
return resolveTaskExecutionModel(task, settings);
}
/**
* Resolve the effective validator model following the dashboard display resolution order.
* Merger display intentionally reuses this reviewer/validator lane in TaskDetailModal.
*/
export function resolveEffectiveValidator(
task: Task | TaskDetail,
logEntries: AgentLogEntry[],
assignedAgent: Agent | null,
settings?: Settings,
columnFlags?: Parameters<typeof isWipColumnRole>[0],
): ModelSelection {
const fromLog = extractReviewerModelFromLog(logEntries);
if (fromLog) return fromLog;
/*
FNXC:WorkflowResolvedColumns 2026-07-30-21:30 (batch-dashboard-app):
WIP role, resolved; `columnFlags` omitted -> the legacy id.
This decides whether the ASSIGNED AGENT's runtime model is the effective one — true only while the
card is actually being worked. Keyed on the literal, a card executing in a renamed wip lane fell
through to the configured default, so the dashboard displayed a different model than the one the
running agent was using. Wrong in the quietest possible way: a plausible model name, for the whole
duration of the run.
*/
if (ACTIVE_STATUSES.has(task.status ?? "") || isWipColumnRole(columnFlags, task.column)) {
const assignedModel = extractAssignedRuntimeModel(assignedAgent);
if (assignedModel.provider && assignedModel.modelId) {
return assignedModel;
}
}
return resolveTaskValidatorModel(task, settings);
}
/**
* Extract planning model from agent log entries.
* Looks for status or text entries with agent role "triage" matching either pattern:
* "Planning using model: <provider>/<modelId>"
* "Triage using model: <provider>/<modelId>" (legacy)
* Returns the latest match, or null if none found.
*/
export function extractPlanningModelFromLog(entries: AgentLogEntry[]): { provider: string; modelId: string } | null {
let result: { provider: string; modelId: string } | null = null;
entries.forEach((entry) => {
if (entry.agent !== PLANNER_AGENT_ROLE || !isEngineMarkerEntryType(entry.type)) return;
const match = parseRuntimeModelMarker(entry.text, "Planning");
if (match) {
result = match;
}
});
return result;
}
/**
* Resolve the effective planning model following the preserved dashboard order:
* 1. Per-task planningModelProvider/planningModelId override
* 2. Runtime triage model from agent log marker
* 3. Project/global planning lane fallback
*/
export function resolveEffectivePlanning(
task: Task | TaskDetail,
logEntries: AgentLogEntry[],
settings?: Settings,
): ModelSelection {
if (task.planningModelProvider && task.planningModelId) {
return {
provider: task.planningModelProvider,
modelId: task.planningModelId,
...(task.planningCredentialInstanceId ? { credentialInstanceId: task.planningCredentialInstanceId } : {}),
};
}
const fromLog = extractPlanningModelFromLog(logEntries);
if (fromLog) {
return fromLog;
}
return resolveTaskPlanningModel(task, settings);
}