feat(insights): model selector with favorites, provider filtering, and configured-providers-only API

- Wire up CustomModelDropdown in InsightsView with favorites support:
  favoriteProviders, favoriteModels, and toggle callbacks
- Auto-promote resolved planning provider as implicit favorite when
  no explicit favorites are set
- Filter /models API to only return providers with credentials in
  Fusion's own auth stores (primary + legacy .pi + models.json),
  excluding supplemental stores (Codex CLI, Claude Code, env vars)
  that surface unconfigured providers
- Still allow enabled extension providers (Claude CLI, Droid CLI,
  llama.cpp) when their settings toggles are on

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Timothy Laurent
2026-05-06 17:45:18 -07:00
parent 65d15f77b5
commit 86712d4966
6 changed files with 125 additions and 2 deletions

View File

@@ -25,7 +25,7 @@ import {
Settings,
} from "lucide-react";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { fetchModels, type ModelInfo } from "../api";
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";
@@ -90,12 +90,57 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, 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)).catch(() => {});
fetchModels()
.then((res) => {
setFetchedModels(res.models);
setFavoriteProviders(res.favoriteProviders);
setFavoriteModels(res.favoriteModels);
setResolvedPlanningProvider(res.resolvedPlanningProvider);
})
.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) {
@@ -483,6 +528,10 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model
label="Insight generation model"
disabled={isRunInFlight}
id="insight-model-select"
favoriteProviders={effectiveFavoriteProviders}
favoriteModels={favoriteModels}
onToggleFavorite={handleToggleProviderFavorite}
onToggleModelFavorite={handleToggleModelFavorite}
/>
</div>
)}

View File

@@ -1,7 +1,67 @@
import { existsSync, readFileSync } from "node:fs";
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.
*/
function getConfiguredProviderNames(): 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) {
if (!existsSync(authPath)) continue;
try {
const parsed = JSON.parse(readFileSync(authPath, "utf-8")) as Record<string, unknown>;
for (const key of Object.keys(parsed)) {
providers.add(key);
}
} catch {
// Ignore 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) {
if (!existsSync(modelsPath)) continue;
try {
const parsed = JSON.parse(readFileSync(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 invalid models.json
}
}
return providers;
}
export const registerModelRoutes: ApiRouteRegistrar = (ctx) => {
const { router, options, store, runtimeLogger } = ctx;
@@ -93,6 +153,20 @@ 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 = getConfiguredProviderNames();
if (useClaudeCli) configuredProviders.add("pi-claude-cli");
if (useDroidCli) configuredProviders.add("droid-cli");
if (useLlamaCpp) configuredProviders.add("llama-server");
if (configuredProviders.size > 0) {
models = models.filter((m) => configuredProviders.has(m.provider));
}
res.json({
models,
favoriteProviders,