diff --git a/ui/app/(prowler)/lighthouse/_lib/model-loading.test.ts b/ui/app/(prowler)/lighthouse/_lib/model-loading.test.ts new file mode 100644 index 0000000000..7298b7243a --- /dev/null +++ b/ui/app/(prowler)/lighthouse/_lib/model-loading.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { + LighthouseV2Configuration, + LighthouseV2ProviderType, + LighthouseV2SupportedModel, +} from "@/app/(prowler)/lighthouse/_types"; + +import { loadLighthouseV2ConnectedModels } from "./model-loading"; + +describe("loadLighthouseV2ConnectedModels", () => { + it("loads models only for connected configurations and initializes the rest as empty", async () => { + // Given + const openAIModel = model("gpt-5.5"); + const loadModels = vi.fn(async (providerType: LighthouseV2ProviderType) => { + if (providerType === "openai-compatible") { + return { error: "Connection failed", status: 400 }; + } + + return { data: [openAIModel] }; + }); + + // When + const result = await loadLighthouseV2ConnectedModels( + [ + configuration("openai", true), + configuration("bedrock", false), + configuration("openai-compatible", false), + ], + loadModels, + ); + + // Then + expect(loadModels).toHaveBeenCalledTimes(1); + expect(loadModels).toHaveBeenCalledWith("openai"); + expect(result.modelsByProvider).toEqual({ + openai: [openAIModel], + bedrock: [], + "openai-compatible": [], + }); + expect(result.failedModelProviders).toEqual([]); + }); + + it("reports model-loading failures only for connected configurations", async () => { + // Given + const loadModels = vi.fn(async (providerType: LighthouseV2ProviderType) => { + if (providerType === "bedrock") { + return { error: "Bedrock models unavailable", status: 503 }; + } + + throw new Error(`Disconnected provider should not load: ${providerType}`); + }); + + // When + const result = await loadLighthouseV2ConnectedModels( + [ + configuration("openai", false), + configuration("bedrock", true), + configuration("openai-compatible", false), + ], + loadModels, + ); + + // Then + expect(loadModels).toHaveBeenCalledTimes(1); + expect(loadModels).toHaveBeenCalledWith("bedrock"); + expect(result.modelsByProvider).toEqual({ + openai: [], + bedrock: [], + "openai-compatible": [], + }); + expect(result.failedModelProviders).toEqual(["bedrock"]); + }); + + it("dedupes provider types and treats null connection state as disconnected", async () => { + // Given + const openAIModel = model("gpt-5.5"); + const loadModels = vi.fn(async () => ({ data: [openAIModel] })); + + // When + const result = await loadLighthouseV2ConnectedModels( + [ + configuration("openai", true), + { ...configuration("openai", true), id: "config-openai-2" }, + { ...configuration("bedrock", false), connected: null }, + ], + loadModels, + ); + + // Then + expect(loadModels).toHaveBeenCalledTimes(1); + expect(loadModels).toHaveBeenCalledWith("openai"); + expect(result.modelsByProvider.openai).toEqual([openAIModel]); + expect(result.modelsByProvider.bedrock).toEqual([]); + }); +}); + +function configuration( + providerType: LighthouseV2ProviderType, + connected: boolean, +): LighthouseV2Configuration { + return { + id: `config-${providerType}`, + providerType, + baseUrl: + providerType === "openai-compatible" ? "https://example.com" : null, + defaultModel: null, + businessContext: "Production account", + connected, + connectionLastCheckedAt: null, + insertedAt: "2026-06-24T09:00:00Z", + updatedAt: "2026-06-24T10:00:00Z", + }; +} + +function model(id: string): LighthouseV2SupportedModel { + return { + id, + name: id, + maxInputTokens: null, + maxOutputTokens: null, + supportsFunctionCalling: true, + supportsVision: false, + supportsReasoning: true, + }; +} diff --git a/ui/app/(prowler)/lighthouse/_lib/model-loading.ts b/ui/app/(prowler)/lighthouse/_lib/model-loading.ts new file mode 100644 index 0000000000..cbac91559e --- /dev/null +++ b/ui/app/(prowler)/lighthouse/_lib/model-loading.ts @@ -0,0 +1,79 @@ +import { + LIGHTHOUSE_V2_PROVIDER_TYPE, + type LighthouseV2Configuration, + type LighthouseV2ProviderType, + type LighthouseV2SupportedModel, +} from "@/app/(prowler)/lighthouse/_types"; + +interface LighthouseV2SupportedModelsSuccess { + data: LighthouseV2SupportedModel[]; +} + +interface LighthouseV2SupportedModelsFailure { + error: string; + errors?: unknown[]; + status?: number; +} + +type LighthouseV2SupportedModelsResult = + | LighthouseV2SupportedModelsSuccess + | LighthouseV2SupportedModelsFailure; + +type LoadLighthouseV2SupportedModels = ( + providerType: LighthouseV2ProviderType, +) => Promise; + +interface LighthouseV2ConnectedModelsResult { + modelsByProvider: Record< + LighthouseV2ProviderType, + LighthouseV2SupportedModel[] + >; + failedModelProviders: LighthouseV2ProviderType[]; +} + +export function createEmptyLighthouseV2ModelsByProvider(): Record< + LighthouseV2ProviderType, + LighthouseV2SupportedModel[] +> { + return { + [LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI]: [], + [LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK]: [], + [LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE]: [], + }; +} + +export async function loadLighthouseV2ConnectedModels( + configurations: LighthouseV2Configuration[], + loadModels: LoadLighthouseV2SupportedModels, +): Promise { + const modelsByProvider = createEmptyLighthouseV2ModelsByProvider(); + // Disconnected providers keep the [] pre-seed and never hit the models + // endpoint, so they can't surface spurious load failures either. + const connectedProviderTypes = Array.from( + new Set( + configurations + .filter((configuration) => configuration.connected === true) + .map((configuration) => configuration.providerType), + ), + ); + + const modelsEntries = await Promise.all( + connectedProviderTypes.map(async (providerType) => { + const result = await loadModels(providerType); + return [providerType, result] as const; + }), + ); + + const failedModelProviders: LighthouseV2ProviderType[] = []; + + modelsEntries.forEach(([providerType, result]) => { + if ("data" in result) { + modelsByProvider[providerType] = result.data; + return; + } + + failedModelProviders.push(providerType); + }); + + return { modelsByProvider, failedModelProviders }; +} diff --git a/ui/app/(prowler)/lighthouse/page.tsx b/ui/app/(prowler)/lighthouse/page.tsx index 59b28ced43..0724e2fa45 100644 --- a/ui/app/(prowler)/lighthouse/page.tsx +++ b/ui/app/(prowler)/lighthouse/page.tsx @@ -13,11 +13,8 @@ import { } from "@/app/(prowler)/lighthouse/_actions"; import { LighthouseV2ChatPage } from "@/app/(prowler)/lighthouse/_components/chat"; import { LighthouseV2NavigationModeSync } from "@/app/(prowler)/lighthouse/_components/navigation"; +import { loadLighthouseV2ConnectedModels } from "@/app/(prowler)/lighthouse/_lib/model-loading"; import { buildLighthouseV2StreamUrl } from "@/app/(prowler)/lighthouse/_lib/stream-url"; -import type { - LighthouseV2ProviderType, - LighthouseV2SupportedModel, -} from "@/app/(prowler)/lighthouse/_types"; import { LighthouseIcon } from "@/components/icons/Icons"; import { Chat } from "@/components/lighthouse-v1"; import { ContentLayout } from "@/components/ui"; @@ -53,25 +50,14 @@ export default async function AIChatbot({ return redirect("/lighthouse/settings"); } - const modelsEntries = await Promise.all( - configurations.map(async (configuration) => { - const result = await getLighthouseV2SupportedModels( - configuration.providerType, - ); - return [configuration.providerType, result] as const; - }), - ); - const modelsByProvider = Object.fromEntries( - modelsEntries.map(([providerType, result]) => [ - providerType, - "data" in result ? result.data : [], - ]), - ) as Record; - // Surface (rather than silently swallow to []) providers whose models - // failed to load, so an empty model list reads as a real backend failure. - const failedModelProviders = modelsEntries - .filter(([, result]) => !("data" in result)) - .map(([providerType]) => providerType); + const { modelsByProvider, failedModelProviders } = + await loadLighthouseV2ConnectedModels( + configurations, + getLighthouseV2SupportedModels, + ); + // Surface (rather than silently swallow to []) connected providers whose + // models failed to load, so their empty list reads as a real backend + // failure. Disconnected providers are never fetched (see model-loading.ts). const modelsError = failedModelProviders.length > 0 ? `Could not load available models for: ${failedModelProviders.join(", ")}. Try again shortly.`