Merge pull request #56 from timothyjlaurent/timothyjlaurent/Anthropic-error

fix: settings scope leak, insights model selector & provider filtering
This commit is contained in:
gsxdsm
2026-05-09 10:58:48 -07:00
committed by GitHub
16 changed files with 631 additions and 10 deletions

View File

@@ -3131,6 +3131,25 @@ describe("TaskStore", () => {
const settings = await store.getSettingsFast();
expect(settings.experimentalFeatures).toEqual({ "fast-feature": true });
});
it("project-level experimentalFeatures does not override global value", async () => {
// Set global experimentalFeatures
await store.updateGlobalSettings({ experimentalFeatures: { insights: true, roadmap: true } });
// Simulate stale project-level config with empty experimentalFeatures
// (can happen from older clients or direct DB writes)
store.getDatabase()
.prepare("UPDATE config SET settings = ? WHERE id = 1")
.run(JSON.stringify({ experimentalFeatures: {} }));
// getSettingsFast should ignore the project-level global key
const fastSettings = await store.getSettingsFast();
expect(fastSettings.experimentalFeatures).toEqual({ insights: true, roadmap: true });
// getSettings should also ignore the project-level global key
const settings = await store.getSettings();
expect(settings.experimentalFeatures).toEqual({ insights: true, roadmap: true });
});
});
// ── Concurrent stress test ───────────────────────────────────────

View File

@@ -1690,10 +1690,15 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
this.globalSettingsStore.getSettings(),
this.readConfig(),
]);
// Strip global-only keys from project-level settings so stale project-scoped
// values don't override the correct global value during the spread merge.
const projectSettings = Object.fromEntries(
Object.entries(config.settings ?? {}).filter(([key]) => !isGlobalSettingsKey(key)),
);
return canonicalizeSettings({
...DEFAULT_SETTINGS,
...globalSettings,
...config.settings,
...projectSettings,
});
}
@@ -1715,7 +1720,17 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
this.db.prepare("SELECT settings FROM config WHERE id = 1").get() as { settings?: string } | undefined,
]);
const projectSettings = row?.settings ? fromJson<Settings>(row.settings) : undefined;
const raw = row?.settings ? fromJson<Settings>(row.settings) : undefined;
// Strip global-only keys from the project-level row so stale project-scoped
// values (e.g. an empty experimentalFeatures={}) don't override the correct
// global value during the spread merge below. getSettingsByScopeFast() has
// always done this; getSettingsFast() was missing the filter.
const projectSettings: Partial<Settings> | undefined = raw
? (Object.fromEntries(
Object.entries(raw).filter(([key]) => !isGlobalSettingsKey(key)),
) as Partial<Settings>)
: undefined;
return canonicalizeSettings({
...DEFAULT_SETTINGS,

View File

@@ -82,6 +82,7 @@ import {
type GlobalConcurrencyState,
type ExecutorStats,
type ExecutorState,
triggerInsightRun,
} from "../api";
import type { Task, TaskDetail, BatchStatusResponse, MergeResult } from "@fusion/core";
import { clearAuthToken } from "../auth";
@@ -1073,6 +1074,55 @@ describe("batchUpdateTaskModels", () => {
});
});
describe("triggerInsightRun", () => {
const originalFetch = globalThis.fetch;
beforeEach(() => {
clearAuthToken();
localStorage.removeItem("fn.authToken");
});
afterEach(() => {
globalThis.fetch = originalFetch;
clearAuthToken();
localStorage.removeItem("fn.authToken");
});
it("sends POST to /api/insights/run without model params by default", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { id: "INSR-test", status: "completed" }));
await triggerInsightRun("manual");
const call = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
const body = JSON.parse(call[1].body);
expect(body).not.toHaveProperty("modelProvider");
expect(body).not.toHaveProperty("modelId");
expect(body.trigger).toBe("manual");
});
it("includes modelProvider and modelId in POST body when provided", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { id: "INSR-test", status: "completed" }));
await triggerInsightRun("manual", undefined, undefined, "openai", "gpt-4o");
const call = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
const body = JSON.parse(call[1].body);
expect(body.modelProvider).toBe("openai");
expect(body.modelId).toBe("gpt-4o");
});
it("omits model params when provider is empty string", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { id: "INSR-test", status: "completed" }));
await triggerInsightRun("manual", undefined, undefined, "", "");
const call = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
const body = JSON.parse(call[1].body);
expect(body).not.toHaveProperty("modelProvider");
expect(body).not.toHaveProperty("modelId");
});
});
describe("task review data api wrappers", () => {
it("fetchTaskReviewData calls task review endpoint", async () => {

View File

@@ -0,0 +1,146 @@
// @vitest-environment jsdom
/**
* Insight Model Selector — TDD Red Phase
*
* These tests define the contract for the collapsible model-selector gear button
* on InsightsView. They are expected to FAIL until the UI is implemented (Task 4).
*
* Contract:
* 1. Gear icon button (data-testid="toggle-model-config") toggles a config row.
* 2. Config row (data-testid="model-config") is hidden by default.
* 3. CustomModelDropdown (data-testid="model-dropdown") appears inside the row.
* 4. Selected model persists to localStorage key "fusion-insight-model".
* 5. On mount, the stored model is restored into the dropdown.
* 6. A yellow indicator dot (class "insights-model-indicator") appears on the gear
* when a non-default model is selected.
*/
import { describe, it, expect, vi, beforeEach, beforeAll, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import * as jestDomMatchers from "@testing-library/jest-dom/matchers";
import { InsightsView } from "../components/InsightsView";
// Register jest-dom matchers (setup files not running in this environment)
expect.extend(jestDomMatchers);
// Ensure localStorage is available (jsdom in this environment may not provide it)
const localStorageStore: Record<string, string> = {};
beforeAll(() => {
if (typeof localStorage === "undefined" || typeof localStorage.clear !== "function") {
const mock = {
getItem: (key: string) => localStorageStore[key] ?? null,
setItem: (key: string, value: string) => {
localStorageStore[key] = value;
},
removeItem: (key: string) => {
delete localStorageStore[key];
},
clear: () => {
Object.keys(localStorageStore).forEach((k) => delete localStorageStore[k]);
},
get length() {
return Object.keys(localStorageStore).length;
},
key: (index: number) => Object.keys(localStorageStore)[index] ?? null,
};
Object.defineProperty(globalThis, "localStorage", { value: mock, writable: true });
}
});
// Mock useInsights hook
vi.mock("../hooks/useInsights", () => ({
useInsights: () => ({
sections: [],
loading: false,
error: null,
latestRun: null,
isRunInFlight: false,
runError: null,
refresh: vi.fn(),
runInsights: vi.fn(),
dismiss: vi.fn(),
createTask: vi.fn(),
archive: vi.fn(),
unarchive: vi.fn(),
toggleShowArchived: vi.fn(),
dismissStates: new Map(),
createTaskStates: new Map(),
archiveStates: new Map(),
unarchiveStates: new Map(),
totalCount: 0,
dismissedCount: 0,
archivedCount: 0,
showArchived: false,
}),
}));
// Mock CustomModelDropdown since it has complex portal behavior
vi.mock("../components/CustomModelDropdown", () => ({
CustomModelDropdown: ({ value, onChange, placeholder }: any) => (
<div data-testid="model-dropdown">
<span data-testid="model-value">{value || placeholder}</span>
<button data-testid="model-change" onClick={() => onChange("openai/gpt-4o")} />
</div>
),
}));
const mockAddToast = vi.fn();
describe("Insight model selector", () => {
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
cleanup();
});
it("renders a gear button to toggle model config", () => {
render(<InsightsView addToast={mockAddToast} />);
expect(screen.getByTestId("toggle-model-config")).toBeInTheDocument();
});
it("does not show model config row by default", () => {
render(<InsightsView addToast={mockAddToast} />);
expect(screen.queryByTestId("model-config")).not.toBeInTheDocument();
});
it("shows model config row when gear is clicked", () => {
render(<InsightsView addToast={mockAddToast} />);
fireEvent.click(screen.getByTestId("toggle-model-config"));
expect(screen.getByTestId("model-config")).toBeInTheDocument();
expect(screen.getByTestId("model-dropdown")).toBeInTheDocument();
});
it("passes models prop to CustomModelDropdown", () => {
const models = [
{ id: "gpt-4o", provider: "openai", name: "GPT-4o" },
];
render(<InsightsView addToast={mockAddToast} models={models as any} />);
fireEvent.click(screen.getByTestId("toggle-model-config"));
expect(screen.getByTestId("model-dropdown")).toBeInTheDocument();
});
it("persists selected model to localStorage", () => {
render(<InsightsView addToast={mockAddToast} />);
fireEvent.click(screen.getByTestId("toggle-model-config"));
fireEvent.click(screen.getByTestId("model-change"));
expect(localStorage.getItem("fusion-insight-model")).toBe("openai/gpt-4o");
});
it("restores model from localStorage on mount", () => {
localStorage.setItem("fusion-insight-model", "anthropic/claude-sonnet-4-5");
render(<InsightsView addToast={mockAddToast} />);
fireEvent.click(screen.getByTestId("toggle-model-config"));
expect(screen.getByTestId("model-value")).toHaveTextContent("anthropic/claude-sonnet-4-5");
});
it("shows indicator dot on gear when a model is selected", () => {
render(<InsightsView addToast={mockAddToast} />);
fireEvent.click(screen.getByTestId("toggle-model-config"));
fireEvent.click(screen.getByTestId("model-change"));
expect(
screen.getByTestId("toggle-model-config").querySelector(".insights-model-indicator"),
).toBeInTheDocument();
});
});

View File

@@ -8865,10 +8865,15 @@ export function triggerInsightRun(
trigger: InsightRunTrigger = "manual",
inputMetadata?: InsightRun["inputMetadata"],
projectId?: string,
modelProvider?: string,
modelId?: string,
): Promise<InsightRun> {
const body: Record<string, unknown> = { trigger, inputMetadata };
if (modelProvider) body.modelProvider = modelProvider;
if (modelId) body.modelId = modelId;
return api<InsightRun>(withProjectId("/insights/run", projectId), {
method: "POST",
body: JSON.stringify({ trigger, inputMetadata }),
body: JSON.stringify(body),
});
}

View File

@@ -702,6 +702,8 @@
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
min-width: 140px;
max-height: min(70vh, calc(var(--space-2xl) * 17));
overflow-y: auto;
z-index: 200;
padding: var(--space-xs) 0;
}

View File

@@ -44,6 +44,42 @@
gap: var(--space-sm);
}
/* Model configuration row — collapsible, below the action bar */
.insights-model-config {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-lg);
border-bottom: 1px solid var(--border);
background: var(--bg-subtle, var(--bg));
}
.insights-model-label {
font-size: 0.8125rem;
color: var(--text-muted);
white-space: nowrap;
flex-shrink: 0;
}
.insights-model-config .model-combobox {
max-width: 320px;
}
/* Gear toggle with active indicator */
.insights-model-toggle {
position: relative;
}
.insights-model-indicator {
position: absolute;
top: 2px;
right: 2px;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--todo, #f59e0b);
}
.insights-view-close {
color: var(--text-muted);
}
@@ -578,4 +614,12 @@
.insights-view-count {
font-size: 0.75rem;
}
.insights-model-config {
padding: var(--space-xs) var(--space-md);
}
.insights-model-config .model-combobox {
max-width: 200px;
}
}

View File

@@ -22,7 +22,10 @@ import {
Archive,
ArchiveRestore,
Clock,
Settings,
} from "lucide-react";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { fetchModels, updateGlobalSettings, type ModelInfo } from "../api";
import { useInsights, type InsightSection } from "../hooks/useInsights";
import type { InsightCategory } from "@fusion/core";
import type { ToastType } from "../hooks/useToast";
@@ -32,6 +35,7 @@ interface InsightsViewProps {
addToast: (message: string, type?: ToastType) => void;
onClose?: () => void;
onCreateTask?: (payload: { insightId: string; title: string; description: string }) => Promise<void>;
models?: ModelInfo[];
}
const CATEGORY_ICONS: Record<InsightCategory, React.ComponentType<{ size?: number; className?: string }>> = {
@@ -52,7 +56,7 @@ const CATEGORY_ICONS: Record<InsightCategory, React.ComponentType<{ size?: numbe
other: Sparkles,
};
export function InsightsView({ projectId, addToast, onClose, onCreateTask }: InsightsViewProps) {
export function InsightsView({ projectId, addToast, onClose, onCreateTask, models: modelsProp }: InsightsViewProps) {
const {
sections,
loading,
@@ -79,6 +83,83 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask }: Ins
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [statusType, setStatusType] = useState<"success" | "error" | "info">("info");
const [showModelConfig, setShowModelConfig] = useState(false);
const [selectedModel, setSelectedModel] = useState<string>(
() => localStorage.getItem("fusion-insight-model") ?? ""
);
// Fetch models internally if not provided via prop
const [fetchedModels, setFetchedModels] = useState<ModelInfo[]>([]);
const [favoriteProviders, setFavoriteProviders] = useState<string[]>([]);
const [favoriteModels, setFavoriteModels] = useState<string[]>([]);
const [resolvedPlanningProvider, setResolvedPlanningProvider] = useState<string | undefined>();
useEffect(() => {
if (modelsProp) return;
fetchModels()
.then((res) => {
setFetchedModels(res.models);
setFavoriteProviders(res.favoriteProviders);
setFavoriteModels(res.favoriteModels);
setResolvedPlanningProvider(res.resolvedPlanningProvider);
// Clear persisted model override if the model is no longer available
const savedModel = localStorage.getItem("fusion-insight-model");
if (savedModel) {
const available = res.models.some((m) => `${m.provider}/${m.id}` === savedModel);
if (!available) {
localStorage.removeItem("fusion-insight-model");
setSelectedModel("");
}
}
})
.catch(() => {});
}, [modelsProp]);
const models = modelsProp ?? fetchedModels;
// Auto-promote the resolved planning provider as a favorite when the user
// hasn't explicitly starred any providers. This ensures the provider they
// actively use always appears at the top of the dropdown.
const effectiveFavoriteProviders = useMemo(() => {
if (favoriteProviders.length > 0) return favoriteProviders;
if (resolvedPlanningProvider) return [resolvedPlanningProvider];
return [];
}, [favoriteProviders, resolvedPlanningProvider]);
const handleToggleProviderFavorite = useCallback(async (provider: string) => {
const isFavorite = favoriteProviders.includes(provider);
const next = isFavorite
? favoriteProviders.filter((p) => p !== provider)
: [provider, ...favoriteProviders];
setFavoriteProviders(next);
try {
await updateGlobalSettings({ favoriteProviders: next, favoriteModels });
} catch {
setFavoriteProviders(favoriteProviders);
}
}, [favoriteProviders, favoriteModels]);
const handleToggleModelFavorite = useCallback(async (modelId: string) => {
const isFavorite = favoriteModels.includes(modelId);
const next = isFavorite
? favoriteModels.filter((m) => m !== modelId)
: [modelId, ...favoriteModels];
setFavoriteModels(next);
try {
await updateGlobalSettings({ favoriteProviders, favoriteModels: next });
} catch {
setFavoriteModels(favoriteModels);
}
}, [favoriteModels, favoriteProviders]);
const handleModelChange = useCallback((value: string) => {
setSelectedModel(value);
if (value) {
localStorage.setItem("fusion-insight-model", value);
} else {
localStorage.removeItem("fusion-insight-model");
}
}, []);
const populatedSections = useMemo(
() => sections.filter((section) => section.items.length > 0),
[sections],
@@ -114,7 +195,22 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask }: Ins
try {
setStatusMessage("Generating insights...");
setStatusType("info");
await runInsights();
let modelProvider: string | undefined;
let modelId: string | undefined;
if (selectedModel) {
const slashIdx = selectedModel.indexOf("/");
if (slashIdx !== -1) {
const provider = selectedModel.slice(0, slashIdx);
const id = selectedModel.slice(slashIdx + 1);
if (provider && id) {
modelProvider = provider;
modelId = id;
}
}
}
await runInsights(modelProvider, modelId);
setStatusMessage("Insight generation started");
setStatusType("success");
addToast("Insight generation started", "success");
@@ -124,7 +220,7 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask }: Ins
setStatusType("error");
addToast(message, "error");
}
}, [runInsights, addToast]);
}, [runInsights, addToast, selectedModel]);
const handleDismiss = useCallback(
async (id: string, title: string) => {
@@ -396,6 +492,17 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask }: Ins
<RefreshCw size={14} className={loading ? "spin" : ""} />
Refresh
</button>
<button
className="btn btn-sm insights-model-toggle"
onClick={() => setShowModelConfig((prev) => !prev)}
aria-label="Configure insight generation model"
aria-expanded={showModelConfig}
data-testid="toggle-model-config"
title={selectedModel ? `Model: ${selectedModel}` : "Configure model"}
>
<Settings size={14} />
{selectedModel && <span className="insights-model-indicator" />}
</button>
<button
className="btn btn-primary btn-sm"
onClick={() => void handleRun()}
@@ -418,6 +525,27 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask }: Ins
</div>
</div>
{showModelConfig && (
<div className="insights-model-config" data-testid="model-config">
<label htmlFor="insight-model-select" className="insights-model-label">
Model
</label>
<CustomModelDropdown
models={models}
value={selectedModel}
onChange={handleModelChange}
placeholder="Use planning default"
label="Insight generation model"
disabled={isRunInFlight}
id="insight-model-select"
favoriteProviders={effectiveFavoriteProviders}
favoriteModels={favoriteModels}
onToggleFavorite={handleToggleProviderFavorite}
onToggleModelFavorite={handleToggleModelFavorite}
/>
</div>
)}
<div
className="insights-status-region"
aria-live="polite"

View File

@@ -99,7 +99,7 @@ export interface UseInsightsResult {
// Actions
refresh: () => Promise<void>;
runInsights: () => Promise<void>;
runInsights: (modelProvider?: string, modelId?: string) => Promise<void>;
dismiss: (id: string) => Promise<void>;
createTask: (id: string) => Promise<{ title: string; description: string } | null>;
archive: (id: string) => Promise<void>;
@@ -189,12 +189,12 @@ export function useInsights(projectId?: string): UseInsightsResult {
}, []);
// Run insights generation
const runInsights = useCallback(async () => {
const runInsights = useCallback(async (modelProvider?: string, modelId?: string) => {
setIsRunInFlight(true);
setRunError(null);
try {
const run = await triggerInsightRun("manual", undefined, projectId);
const run = await triggerInsightRun("manual", undefined, projectId, modelProvider, modelId);
setLatestRun(run);
if (run.status === "completed") {

View File

@@ -385,6 +385,94 @@ describe("Insights routes", () => {
expect(retried.lifecycle.retryOfRunId).toBe(retryableRun.id);
});
it("POST /api/insights/runs/:id/retry preserves the original run's custom model", async () => {
// Create a run with a custom model that fails with a retryable error
piMocks.promptWithFallback.mockRejectedValue(new Error("HTTP 503"));
const failedRes = await request(
app,
"POST",
"/api/insights/run",
JSON.stringify({
trigger: "manual",
modelProvider: "openai",
modelId: "gpt-4o",
}),
{ "Content-Type": "application/json" },
);
expect(failedRes.status).toBe(201);
const failedRun = failedRes.body as { id: string; inputMetadata: Record<string, unknown> };
// The failed run should have persisted the model info in inputMetadata
expect(failedRun.inputMetadata?.metadata).toMatchObject({
modelProvider: "openai",
modelId: "gpt-4o",
});
// Reset mock so retry succeeds
piMocks.promptWithFallback.mockResolvedValue(undefined);
piMocks.createFnAgent.mockClear();
const retriedRes = await request(app, "POST", `/api/insights/runs/${failedRun.id}/retry`, JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(retriedRes.status).toBe(201);
// The retry should pass the original model to createFnAgent
expect(piMocks.createFnAgent).toHaveBeenCalledWith(
expect.objectContaining({
defaultProvider: "openai",
defaultModelId: "gpt-4o",
}),
);
});
it("POST /api/insights/run passes explicit model override to createFnAgent", async () => {
const res = await request(
app,
"POST",
"/api/insights/run",
JSON.stringify({
trigger: "manual",
modelProvider: "openai",
modelId: "gpt-4o",
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
// createFnAgent should have been called with the explicit override
expect(piMocks.createFnAgent).toHaveBeenCalledWith(
expect.objectContaining({
defaultProvider: "openai",
defaultModelId: "gpt-4o",
fallbackProvider: undefined,
fallbackModelId: undefined,
}),
);
});
it("POST /api/insights/run without model override uses settings resolution", async () => {
const res = await request(
app,
"POST",
"/api/insights/run",
JSON.stringify({
trigger: "manual",
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
// Without override, provider/model come from settings resolution
// (which returns undefined when no planning settings are configured)
expect(piMocks.createFnAgent).toHaveBeenCalledWith(
expect.objectContaining({
fallbackProvider: undefined,
fallbackModelId: undefined,
}),
);
});
it("POST /api/insights/:id/create-task returns task-conversion payload", async () => {
const insight = storeA.getInsightStore().createInsight("", {
title: "Refactor parser",

View File

@@ -18,6 +18,7 @@ import {
InsightLifecycleError,
InsightStore,
executeInsightRunLifecycle,
resolvePlanningSettingsModel,
retryInsightRunLifecycle,
type InsightCategory,
type MemoryInsightCategory,
@@ -26,6 +27,7 @@ import {
type InsightRunTrigger,
type InsightRunListOptions,
type InsightRunStatus,
type Settings,
} from "@fusion/core";
import {
ApiError,
@@ -104,6 +106,9 @@ async function executeInsightAttempt(params: {
runId: string;
signal: AbortSignal;
insightStore: InsightStore;
settings: Settings;
modelProvider?: string;
modelId?: string;
}): Promise<{ summary: string; insightsCreated: number; insightsUpdated: number }> {
const {
readWorkingMemory,
@@ -120,10 +125,23 @@ async function executeInsightAttempt(params: {
throw new Error("No working memory to analyze");
}
const { provider: settingsProvider, modelId: settingsModelId } =
resolvePlanningSettingsModel(params.settings);
const finalProvider = params.modelProvider ?? settingsProvider;
const finalModelId = params.modelId ?? settingsModelId;
const hasCustomModel = params.modelProvider && params.modelId;
const fallbackProvider = hasCustomModel ? settingsProvider : undefined;
const fallbackModelId = hasCustomModel ? settingsModelId : undefined;
const existingInsights = await readInsightsMemory(params.rootDir);
let responseText = "";
const { session } = await createFnAgent({
cwd: params.rootDir,
defaultProvider: finalProvider,
defaultModelId: finalModelId,
fallbackProvider,
fallbackModelId,
systemPrompt: [
"You extract durable project insights from working memory notes.",
"Return only valid JSON that matches the requested schema.",
@@ -309,14 +327,32 @@ export function createInsightsRouter(store: TaskStore): Router {
const taskStore = requestContext.getStore();
if (!taskStore) throw new ApiError(500, "Store context not available");
const rootDir = taskStore.getRootDir();
const settings = await taskStore.getSettings();
const rawProvider = typeof req.body.modelProvider === "string" ? req.body.modelProvider.trim() : undefined;
const rawModelId = typeof req.body.modelId === "string" ? req.body.modelId.trim() : undefined;
// Require both provider and model ID together — partial values are discarded
const modelProvider = rawProvider && rawModelId ? rawProvider : undefined;
const modelId = rawProvider && rawModelId ? rawModelId : undefined;
const controller = new AbortController();
// Stash model selection in inputMetadata.metadata so retries can recover it
const inputMetadata = typeof req.body.inputMetadata === "object" && req.body.inputMetadata !== null
? { ...req.body.inputMetadata }
: {};
if (modelProvider || modelId) {
inputMetadata.metadata = {
...(typeof inputMetadata.metadata === "object" && inputMetadata.metadata !== null ? inputMetadata.metadata : {}),
...(modelProvider ? { modelProvider } : {}),
...(modelId ? { modelId } : {}),
};
}
const run = await executeInsightRunLifecycle({
store: insightStore,
projectId,
input: {
trigger,
inputMetadata: req.body.inputMetadata,
inputMetadata,
},
signal: controller.signal,
timeoutMs: typeof req.body.timeoutMs === "number" ? req.body.timeoutMs : 120_000,
@@ -330,6 +366,9 @@ export function createInsightsRouter(store: TaskStore): Router {
runId: run.id,
signal,
insightStore,
settings,
modelProvider,
modelId,
});
},
});
@@ -474,8 +513,18 @@ export function createInsightsRouter(store: TaskStore): Router {
const taskStore = requestContext.getStore();
if (!taskStore) throw new ApiError(500, "Store context not available");
const rootDir = taskStore.getRootDir();
const settings = await taskStore.getSettings();
const controller = new AbortController();
// Recover model selection from the original run's inputMetadata
const originalMetadata = existing.inputMetadata?.metadata;
const retryModelProvider = typeof (originalMetadata as Record<string, unknown> | undefined)?.modelProvider === "string"
? (originalMetadata as Record<string, unknown>).modelProvider as string
: undefined;
const retryModelId = typeof (originalMetadata as Record<string, unknown> | undefined)?.modelId === "string"
? (originalMetadata as Record<string, unknown>).modelId as string
: undefined;
const { run } = await retryInsightRunLifecycle({
store,
runId: id,
@@ -491,6 +540,9 @@ export function createInsightsRouter(store: TaskStore): Router {
runId: run.id,
signal,
insightStore: store,
settings,
modelProvider: retryModelProvider,
modelId: retryModelId,
});
},
});

View File

@@ -1,7 +1,67 @@
import { access, readFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import { resolvePlanningSettingsModel } from "@fusion/core";
import { ApiError } from "../api-error.js";
import type { ApiRouteRegistrar } from "./types.js";
/**
* Read provider names from Fusion's own auth stores (primary + legacy .pi).
* These represent providers the user has explicitly configured in Fusion,
* as opposed to supplemental credentials inherited from Codex CLI,
* Claude Code, or environment variables.
*/
async function getConfiguredProviderNames(): Promise<Set<string>> {
const home = process.env.HOME || process.env.USERPROFILE || homedir();
const providers = new Set<string>();
// Fusion primary + legacy .pi auth files
const authPaths = [
join(home, ".fusion", "agent", "auth.json"),
join(home, ".pi", "agent", "auth.json"),
join(home, ".pi", "auth.json"),
];
for (const authPath of authPaths) {
try {
await access(authPath);
const parsed = JSON.parse(await readFile(authPath, "utf-8")) as Record<string, unknown>;
for (const key of Object.keys(parsed)) {
providers.add(key);
}
} catch {
// Ignore missing or invalid auth files
}
}
// Check models.json for providers with inline API keys
const modelsPaths = [
join(home, ".fusion", "agent", "models.json"),
join(home, ".pi", "agent", "models.json"),
join(home, ".pi", "models.json"),
];
for (const modelsPath of modelsPaths) {
try {
await access(modelsPath);
const parsed = JSON.parse(await readFile(modelsPath, "utf-8")) as {
providers?: Record<string, { apiKey?: string }>;
};
const provs = parsed?.providers;
if (provs) {
for (const [providerId, config] of Object.entries(provs)) {
if (config.apiKey) {
providers.add(providerId);
}
}
}
} catch {
// Ignore missing or invalid models.json
}
}
return providers;
}
export const registerModelRoutes: ApiRouteRegistrar = (ctx) => {
const { router, options, store, runtimeLogger } = ctx;
@@ -93,6 +153,18 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => {
models = models.filter((m) => m.provider !== "cursor-cli");
}
// Filter to only providers the user has explicitly configured in Fusion.
// getAvailable() checks supplemental credential stores (Codex CLI,
// Claude Code, env vars) which surface providers the user may not
// have set up in Fusion. We restrict to providers with credentials
// in Fusion's own auth stores (primary + legacy .pi + models.json),
// plus any providers enabled via settings toggles (Claude CLI, etc.).
const configuredProviders = await getConfiguredProviderNames();
if (useClaudeCli) configuredProviders.add("pi-claude-cli");
if (useDroidCli) configuredProviders.add("droid-cli");
if (useLlamaCpp) configuredProviders.add("llama-server");
models = models.filter((m) => configuredProviders.has(m.provider));
res.json({
models,
favoriteProviders,

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB