fix(ui): load Lighthouse models only for connected providers

This commit is contained in:
alejandrobailo
2026-07-02 15:22:45 +02:00
parent 2b57c18c06
commit 884f2f5891
3 changed files with 214 additions and 23 deletions
@@ -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,
};
}
@@ -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<LighthouseV2SupportedModelsResult>;
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<LighthouseV2ConnectedModelsResult> {
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 };
}
+9 -23
View File
@@ -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<LighthouseV2ProviderType, LighthouseV2SupportedModel[]>;
// 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.`