fix(ui): persist Lighthouse model and business context per provider

Route Lighthouse model defaults and business context onto the per-provider
configuration system (PATCH /lighthouse/config/<id>) and drop all use of the
legacy tenant configuration (/lighthouse/configuration, is_active) from the UI.

This fixes the 400 'No active configuration found' error when selecting a
Bedrock model in chat, and partially reverts 58539dced. The chat now resolves
the initial provider by a fixed priority (OpenAI > Bedrock > OpenAI-compatible)
and remembers the chosen model as the provider's default_model. Also narrows
the chat model combobox width.
This commit is contained in:
alejandrobailo
2026-06-30 10:02:56 +02:00
parent 58539dced6
commit 56b15e9110
13 changed files with 229 additions and 339 deletions
@@ -2,12 +2,11 @@ import { describe, expect, it } from "vitest";
import {
buildLighthouseV2ConfigurationPayload,
buildLighthouseV2TenantConfigurationUpdatePayload,
buildLighthouseV2ConfigurationUpdatePayload,
mapLighthouseV2Configuration,
mapLighthouseV2Message,
mapLighthouseV2Model,
mapLighthouseV2Provider,
mapLighthouseV2TenantConfiguration,
validateLighthouseV2ConfigurationInput,
} from "./lighthouse-v2.adapter";
@@ -22,6 +21,7 @@ describe("lighthouse-v2.adapter", () => {
provider_type: "bedrock",
base_url: null,
default_model: "anthropic.claude",
business_context: "Production tenant",
connected: true,
connection_last_checked_at: "2026-06-24T10:00:00Z",
inserted_at: "2026-06-24T09:00:00Z",
@@ -38,6 +38,7 @@ describe("lighthouse-v2.adapter", () => {
providerType: "bedrock",
baseUrl: null,
defaultModel: "anthropic.claude",
businessContext: "Production tenant",
connected: true,
connectionLastCheckedAt: "2026-06-24T10:00:00Z",
insertedAt: "2026-06-24T09:00:00Z",
@@ -79,37 +80,6 @@ describe("lighthouse-v2.adapter", () => {
});
});
it("should map tenant model defaults by provider", () => {
// Given
const resource: Parameters<typeof mapLighthouseV2TenantConfiguration>[0] =
{
id: "tenant-config-1",
type: "lighthouse-configurations",
attributes: {
business_context: "Production tenant",
default_provider: "bedrock",
default_models: {
openai: "gpt-5.1",
bedrock: "anthropic.claude-4",
},
},
};
// When
const config = mapLighthouseV2TenantConfiguration(resource);
// Then
expect(config).toEqual({
id: "tenant-config-1",
businessContext: "Production tenant",
defaultProvider: "bedrock",
defaultModels: {
openai: "gpt-5.1",
bedrock: "anthropic.claude-4",
},
});
});
it("should map message parts from backend names", () => {
// Given
const resource: Parameters<typeof mapLighthouseV2Message>[0] = {
@@ -179,34 +149,38 @@ describe("lighthouse-v2.adapter", () => {
expect(payload.data.attributes).not.toHaveProperty("business_context");
});
it("should build tenant preference payloads with provider model maps", () => {
// Given
const input = {
defaultProvider: "bedrock" as const,
defaultModels: {
openai: "gpt-5.1",
bedrock: "anthropic.claude-4",
},
};
it("should build per-provider update payloads with default_model and business_context", () => {
// When
const payload = buildLighthouseV2TenantConfigurationUpdatePayload(input);
const payload = buildLighthouseV2ConfigurationUpdatePayload("config-1", {
defaultModel: "anthropic.claude-4",
businessContext: "Production tenant",
});
// Then
expect(payload).toEqual({
data: {
type: "lighthouse-configurations",
type: "lighthouse-ai-configurations",
id: "config-1",
attributes: {
default_provider: "bedrock",
default_models: {
openai: "gpt-5.1",
bedrock: "anthropic.claude-4",
},
default_model: "anthropic.claude-4",
business_context: "Production tenant",
},
},
});
});
it("should omit untouched fields from the update payload", () => {
// When
const payload = buildLighthouseV2ConfigurationUpdatePayload("config-1", {
defaultModel: "gpt-5.1",
});
// Then
expect(payload.data.attributes).toEqual({ default_model: "gpt-5.1" });
expect(payload.data.attributes).not.toHaveProperty("business_context");
expect(payload.data.attributes).not.toHaveProperty("credentials");
});
it("should require base_url for OpenAI-compatible configurations", () => {
// Given
const input = {
@@ -11,8 +11,6 @@ import {
type LighthouseV2SupportedModel,
type LighthouseV2SupportedProvider,
type LighthouseV2Task,
type LighthouseV2TenantConfiguration,
type LighthouseV2TenantConfigurationUpdateInput,
} from "@/app/(prowler)/lighthouse/_types";
export interface JsonApiResource<TAttributes> {
@@ -35,18 +33,13 @@ interface ConfigurationAttributes {
provider_type: LighthouseV2ProviderType;
base_url: string | null;
default_model?: string | null;
business_context?: string | null;
connected: boolean | null;
connection_last_checked_at: string | null;
inserted_at: string;
updated_at: string;
}
interface TenantConfigurationAttributes {
business_context?: string | null;
default_provider?: LighthouseV2ProviderType | "";
default_models?: Record<string, string> | null;
}
interface SupportedProviderAttributes {
name: string;
}
@@ -122,6 +115,7 @@ export function mapLighthouseV2Configuration(
providerType: resource.attributes.provider_type,
baseUrl: resource.attributes.base_url,
defaultModel: resource.attributes.default_model ?? null,
businessContext: resource.attributes.business_context ?? "",
connected: resource.attributes.connected,
connectionLastCheckedAt: resource.attributes.connection_last_checked_at,
insertedAt: resource.attributes.inserted_at,
@@ -129,17 +123,6 @@ export function mapLighthouseV2Configuration(
};
}
export function mapLighthouseV2TenantConfiguration(
resource: JsonApiResource<TenantConfigurationAttributes>,
): LighthouseV2TenantConfiguration {
return {
id: resource.id,
businessContext: resource.attributes.business_context ?? "",
defaultProvider: resource.attributes.default_provider ?? "",
defaultModels: resource.attributes.default_models ?? {},
};
}
export function mapLighthouseV2Provider(
resource: JsonApiResource<SupportedProviderAttributes>,
): LighthouseV2SupportedProvider {
@@ -228,21 +211,8 @@ export function buildLighthouseV2ConfigurationUpdatePayload(
attributes: filterUndefinedAttributes({
credentials: input.credentials,
base_url: input.baseUrl,
}),
},
};
}
export function buildLighthouseV2TenantConfigurationUpdatePayload(
input: LighthouseV2TenantConfigurationUpdateInput,
) {
return {
data: {
type: "lighthouse-configurations",
attributes: filterUndefinedAttributes({
default_model: input.defaultModel,
business_context: input.businessContext,
default_provider: input.defaultProvider,
default_models: input.defaultModels,
}),
},
};
@@ -26,8 +26,8 @@ vi.mock("@/lib/helper", () => ({
import {
createLighthouseV2Session,
updateLighthouseV2Configuration,
updateLighthouseV2Session,
updateLighthouseV2TenantConfiguration,
} from "./lighthouse-v2";
function sessionResponse(id = "session-1") {
@@ -49,19 +49,21 @@ function sessionResponse(id = "session-1") {
);
}
function tenantConfigurationResponse() {
function configurationResponse(id = "config-1") {
return Response.json(
{
data: {
id: "tenant-config-1",
type: "lighthouse-configurations",
id,
type: "lighthouse-ai-configurations",
attributes: {
provider_type: "bedrock",
base_url: null,
default_model: "anthropic.claude-4",
business_context: "Production tenant",
default_provider: "bedrock",
default_models: {
openai: "gpt-5.1",
bedrock: "anthropic.claude-4",
},
connected: true,
connection_last_checked_at: "2026-06-25T10:00:00Z",
inserted_at: "2026-06-25T09:00:00Z",
updated_at: "2026-06-25T10:00:00Z",
},
},
},
@@ -99,40 +101,35 @@ describe("Lighthouse v2 session write actions", () => {
expect(revalidatePathMock).toHaveBeenCalledWith("/lighthouse");
});
it("updates tenant model preferences without revalidating the active chat route", async () => {
it("persists the chosen model as the provider default without remounting the active chat", async () => {
// Given
const fetchMock = vi.fn().mockResolvedValue(tenantConfigurationResponse());
const fetchMock = vi.fn().mockResolvedValue(configurationResponse());
vi.stubGlobal("fetch", fetchMock);
// When
const result = await updateLighthouseV2TenantConfiguration({
defaultProvider: "bedrock",
defaultModels: {
openai: "gpt-5.1",
bedrock: "anthropic.claude-4",
},
const result = await updateLighthouseV2Configuration("config-1", {
defaultModel: "anthropic.claude-4",
});
// Then
expect("data" in result && result.data.defaultProvider).toBe("bedrock");
expect("data" in result && result.data.defaultModel).toBe(
"anthropic.claude-4",
);
expect(fetchMock).toHaveBeenCalledWith(
new URL("https://api.example.com/api/v1/lighthouse/configuration"),
new URL("https://api.example.com/api/v1/lighthouse/config/config-1"),
expect.objectContaining({
method: "PATCH",
body: JSON.stringify({
data: {
type: "lighthouse-configurations",
attributes: {
default_provider: "bedrock",
default_models: {
openai: "gpt-5.1",
bedrock: "anthropic.claude-4",
},
},
type: "lighthouse-ai-configurations",
id: "config-1",
attributes: { default_model: "anthropic.claude-4" },
},
}),
}),
);
expect(revalidatePathMock).not.toHaveBeenCalled();
// Revalidating the active force-dynamic chat route would remount it and kill
// the live EventSource — only the settings route may be revalidated.
expect(revalidatePathMock).not.toHaveBeenCalledWith("/lighthouse");
});
});
@@ -13,8 +13,6 @@ import type {
LighthouseV2SupportedModel,
LighthouseV2SupportedProvider,
LighthouseV2Task,
LighthouseV2TenantConfiguration,
LighthouseV2TenantConfigurationUpdateInput,
} from "@/app/(prowler)/lighthouse/_types";
import { apiBaseUrl, getAuthHeaders } from "@/lib/helper";
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
@@ -26,7 +24,6 @@ import {
buildLighthouseV2MessagePayload,
buildLighthouseV2SessionCreatePayload,
buildLighthouseV2SessionUpdatePayload,
buildLighthouseV2TenantConfigurationUpdatePayload,
getJsonApiArray,
type JsonApiDocument,
mapLighthouseV2Configuration,
@@ -35,7 +32,6 @@ import {
mapLighthouseV2Provider,
mapLighthouseV2Session,
mapLighthouseV2Task,
mapLighthouseV2TenantConfiguration,
validateLighthouseV2ConfigurationInput,
} from "./lighthouse-v2.adapter";
@@ -106,31 +102,6 @@ export async function deleteLighthouseV2Configuration(
);
}
export async function getLighthouseV2TenantConfiguration(): Promise<
LighthouseV2ActionResult<LighthouseV2TenantConfiguration>
> {
return getSingle(
"/lighthouse/configuration",
mapLighthouseV2TenantConfiguration,
);
}
export async function updateLighthouseV2TenantConfiguration(
input: LighthouseV2TenantConfigurationUpdateInput,
): Promise<LighthouseV2ActionResult<LighthouseV2TenantConfiguration>> {
return mutateSingle(
"/lighthouse/configuration",
{
method: "PATCH",
body: JSON.stringify(
buildLighthouseV2TenantConfigurationUpdatePayload(input),
),
},
mapLighthouseV2TenantConfiguration,
"",
);
}
// Starts the backend connection-check task, polls it to completion (reusing the
// shared task poller), then returns the re-fetched configuration so the caller
// can render the authoritative `connected` / `connectionLastCheckedAt` status.
@@ -62,13 +62,13 @@ const {
createSessionMock,
getMessagesMock,
sendMessageMock,
updateTenantConfigurationMock,
updateConfigurationMock,
} = vi.hoisted(() => ({
cancelRunMock: vi.fn(),
createSessionMock: vi.fn(),
getMessagesMock: vi.fn(),
sendMessageMock: vi.fn(),
updateTenantConfigurationMock: vi.fn(),
updateConfigurationMock: vi.fn(),
}));
vi.mock("@/app/(prowler)/lighthouse/_actions", () => ({
@@ -76,7 +76,7 @@ vi.mock("@/app/(prowler)/lighthouse/_actions", () => ({
createLighthouseV2Session: createSessionMock,
getLighthouseV2Messages: getMessagesMock,
sendLighthouseV2Message: sendMessageMock,
updateLighthouseV2TenantConfiguration: updateTenantConfigurationMock,
updateLighthouseV2Configuration: updateConfigurationMock,
}));
// Streamdown pulls in shiki/wasm syntax highlighting that doesn't run under
@@ -92,6 +92,7 @@ const configurations: LighthouseV2Configuration[] = [
providerType: "openai",
baseUrl: null,
defaultModel: "gpt-5.1",
businessContext: "Production account",
connected: true,
connectionLastCheckedAt: "2026-06-24T10:00:00Z",
insertedAt: "2026-06-24T09:00:00Z",
@@ -102,6 +103,7 @@ const configurations: LighthouseV2Configuration[] = [
providerType: "bedrock",
baseUrl: null,
defaultModel: "anthropic.claude-4",
businessContext: "Production account",
connected: true,
connectionLastCheckedAt: "2026-06-23T10:00:00Z",
insertedAt: "2026-06-23T09:00:00Z",
@@ -115,16 +117,6 @@ const modelsByProvider = {
"openai-compatible": [model("llama-3.3")],
};
const tenantConfiguration = {
id: "tenant-config-1",
businessContext: "Production account",
defaultProvider: "openai",
defaultModels: {
openai: "gpt-5.1",
bedrock: "anthropic.claude-4",
},
} satisfies Parameters<typeof LighthouseV2ChatPage>[0]["tenantConfiguration"];
describe("LighthouseV2ChatPage", () => {
beforeEach(() => {
vi.stubGlobal(
@@ -143,7 +135,7 @@ describe("LighthouseV2ChatPage", () => {
createSessionMock.mockReset();
getMessagesMock.mockReset();
sendMessageMock.mockReset();
updateTenantConfigurationMock.mockReset();
updateConfigurationMock.mockReset();
// The mock never fires "open": the client must POST the message without
// waiting for it (the backend sends no bytes until the worker emits, which
// only happens after the POST). This is the regression guard for the
@@ -170,9 +162,7 @@ describe("LighthouseV2ChatPage", () => {
},
},
});
updateTenantConfigurationMock.mockResolvedValue({
data: tenantConfiguration,
});
updateConfigurationMock.mockResolvedValue({ data: configurations[1] });
});
afterEach(() => {
@@ -229,16 +219,11 @@ describe("LighthouseV2ChatPage", () => {
).not.toHaveClass("border-t");
});
it("sends messages with the tenant default provider and its saved model", async () => {
// Given
it("opens the highest-priority connected provider with its remembered model", async () => {
// Given: both OpenAI and Bedrock are connected; OpenAI outranks Bedrock
const user = userEvent.setup();
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
renderPage({
tenantConfiguration: {
...tenantConfiguration,
defaultProvider: "bedrock",
},
});
renderPage();
// When
await user.type(
@@ -246,13 +231,14 @@ describe("LighthouseV2ChatPage", () => {
["Summarize findings", "{Enter}"].join(""),
);
// Then: the message is sent even though EventSource never fires "open"
// Then: the message is sent with OpenAI and its remembered defaultModel,
// even though EventSource never fires "open"
await waitFor(() =>
expect(sendMessageMock).toHaveBeenCalledWith({
sessionId: "session-1",
text: "Summarize findings",
provider: "bedrock",
model: "anthropic.claude-4",
provider: "openai",
model: "gpt-5.1",
}),
);
expect(createSessionMock).toHaveBeenCalledWith("Summarize findings");
@@ -270,17 +256,14 @@ describe("LighthouseV2ChatPage", () => {
replaceStateSpy.mockRestore();
});
it("falls back to the first connected provider when tenant defaults are invalid", async () => {
// Given
it("opens a lower-priority provider when the higher-priority one is disconnected", async () => {
// Given: only Bedrock is connected
const user = userEvent.setup();
renderPage({
tenantConfiguration: {
...tenantConfiguration,
defaultProvider: "bedrock",
defaultModels: {
bedrock: "missing-model",
},
},
configurations: [
{ ...configurations[0], connected: false },
configurations[1],
],
});
// When
@@ -290,6 +273,33 @@ describe("LighthouseV2ChatPage", () => {
);
// Then
await waitFor(() =>
expect(sendMessageMock).toHaveBeenCalledWith(
expect.objectContaining({
provider: "bedrock",
model: "anthropic.claude-4",
}),
),
);
});
it("falls back to the first supported model when the remembered model is unsupported", async () => {
// Given: OpenAI's remembered default model is no longer offered
const user = userEvent.setup();
renderPage({
configurations: [
{ ...configurations[0], defaultModel: "missing-model" },
configurations[1],
],
});
// When
await user.type(
screen.getByRole("textbox", { name: "Message" }),
["Summarize findings", "{Enter}"].join(""),
);
// Then: OpenAI stays selected (highest priority) but on its first model
await waitFor(() =>
expect(sendMessageMock).toHaveBeenCalledWith(
expect.objectContaining({
@@ -300,7 +310,7 @@ describe("LighthouseV2ChatPage", () => {
);
});
it("persists the selected chat model without deleting other provider defaults", async () => {
it("persists the selected chat model as that provider's default", async () => {
// Given
const user = userEvent.setup();
renderPage();
@@ -311,14 +321,10 @@ describe("LighthouseV2ChatPage", () => {
await screen.findByRole("option", { name: "anthropic.claude-4" }),
);
// Then
// Then: only the chosen provider's config is updated, by id
await waitFor(() =>
expect(updateTenantConfigurationMock).toHaveBeenCalledWith({
defaultProvider: "bedrock",
defaultModels: {
openai: "gpt-5.1",
bedrock: "anthropic.claude-4",
},
expect(updateConfigurationMock).toHaveBeenCalledWith("config-bedrock", {
defaultModel: "anthropic.claude-4",
}),
);
});
@@ -326,7 +332,7 @@ describe("LighthouseV2ChatPage", () => {
it("keeps the chosen model applied and surfaces the backend reason when saving the default fails", async () => {
// Given
const user = userEvent.setup();
updateTenantConfigurationMock.mockResolvedValue({
updateConfigurationMock.mockResolvedValue({
error: "Invalid model 'anthropic.claude-4' for provider 'bedrock'.",
status: 400,
});
@@ -470,7 +476,6 @@ function renderPage(props?: RenderPageProps) {
const componentProps = {
configurations: props?.configurations ?? configurations,
modelsByProvider: props?.modelsByProvider ?? modelsByProvider,
tenantConfiguration: props?.tenantConfiguration ?? tenantConfiguration,
initialSessionId: props?.initialSessionId,
initialMessages: props?.initialMessages ?? [],
initialPrompt: props?.initialPrompt,
@@ -7,7 +7,7 @@ import {
createLighthouseV2Session,
getLighthouseV2Messages,
sendLighthouseV2Message,
updateLighthouseV2TenantConfiguration,
updateLighthouseV2Configuration,
} from "@/app/(prowler)/lighthouse/_actions";
import {
Conversation,
@@ -35,13 +35,13 @@ import {
import { parseStreamEvent } from "@/app/(prowler)/lighthouse/_lib/stream-event-parser";
import { buildLighthouseV2StreamUrl } from "@/app/(prowler)/lighthouse/_lib/stream-url";
import {
LIGHTHOUSE_V2_PROVIDER_TYPE,
LIGHTHOUSE_V2_SSE_EVENT,
type LighthouseV2Configuration,
type LighthouseV2Message,
type LighthouseV2ProviderType,
type LighthouseV2SSEEvent,
type LighthouseV2SupportedModel,
type LighthouseV2TenantConfiguration,
} from "@/app/(prowler)/lighthouse/_types";
import { Card } from "@/components/shadcn";
import {
@@ -61,7 +61,6 @@ interface LighthouseV2ChatPageProps {
LighthouseV2ProviderType,
LighthouseV2SupportedModel[]
>;
tenantConfiguration?: LighthouseV2TenantConfiguration;
initialSessionId?: string;
initialMessages: LighthouseV2Message[];
initialActiveTaskId?: string | null;
@@ -72,7 +71,6 @@ interface LighthouseV2ChatPageProps {
export function LighthouseV2ChatPage({
configurations,
modelsByProvider,
tenantConfiguration,
initialSessionId,
initialMessages,
initialActiveTaskId,
@@ -87,13 +85,9 @@ export function LighthouseV2ChatPage({
const initialModelSelection = resolveInitialModelSelection(
connectedConfigurations,
modelsByProvider,
tenantConfiguration,
);
const [selectedModelSelection, setSelectedModelSelection] =
useState<LighthouseV2ModelSelection | null>(initialModelSelection);
const [tenantModelDefaults, setTenantModelDefaults] = useState<
Record<string, string>
>(tenantConfiguration?.defaultModels ?? {});
const [modelPreferenceSaving, setModelPreferenceSaving] = useState(false);
const [activeSessionId, setActiveSessionId] = useState<string | null>(
initialSessionId ?? null,
@@ -313,35 +307,27 @@ export function LighthouseV2ChatPage({
selection: LighthouseV2ModelSelection,
) => {
// The selection drives the model used for the next message, so it stays
// applied even if persisting it as the tenant default fails. Only the
// saved-default mirror is rolled back on failure — reverting the active
// selection would make a connected provider unusable when the save 4xxs.
const previousDefaults = tenantModelDefaults;
// applied even if persisting it as the provider's default model fails —
// reverting it would make a connected provider unusable when the save 4xxs.
setSelectedModelSelection(selection);
setFeedback(null);
const nextDefaults = {
...tenantModelDefaults,
[selection.providerType]: selection.modelId,
};
setTenantModelDefaults(nextDefaults);
const configId = connectedConfigurations.find(
(configuration) => configuration.providerType === selection.providerType,
)?.id;
if (!configId) return;
setModelPreferenceSaving(true);
const result = await updateLighthouseV2TenantConfiguration({
defaultProvider: selection.providerType,
defaultModels: nextDefaults,
const result = await updateLighthouseV2Configuration(configId, {
defaultModel: selection.modelId,
});
setModelPreferenceSaving(false);
if ("error" in result) {
setTenantModelDefaults(previousDefaults);
setFeedback(result.error);
return;
}
setTenantModelDefaults(result.data.defaultModels);
};
const handleSubmit = (event: SubmitEvent<HTMLFormElement>) => {
@@ -421,7 +407,7 @@ export function LighthouseV2ChatPage({
input,
isStreaming: Boolean(streamState.activeTaskId),
modelSelector: (
<div className="min-w-0 flex-1 sm:max-w-80">
<div className="min-w-0 flex-1 sm:max-w-48">
<Combobox
aria-label="Model"
value={selectedModelValue}
@@ -493,71 +479,47 @@ function replaceLighthouseV2SessionUrl(sessionId: string | null) {
window.history.replaceState(window.history.state, "", url);
}
// Fixed precedence used to pick which connected provider opens the chat. Any
// provider outside this list keeps its relative order behind these.
const LIGHTHOUSE_V2_PROVIDER_PRIORITY = [
LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI,
LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK,
LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE,
] as const;
function resolveInitialModelSelection(
connectedConfigurations: LighthouseV2Configuration[],
modelsByProvider: Record<
LighthouseV2ProviderType,
LighthouseV2SupportedModel[]
>,
tenantConfiguration?: LighthouseV2TenantConfiguration,
): LighthouseV2ModelSelection | null {
const tenantDefaultProvider = tenantConfiguration?.defaultProvider;
const priorityIndex = (providerType: LighthouseV2ProviderType) => {
const index = LIGHTHOUSE_V2_PROVIDER_PRIORITY.indexOf(providerType);
return index === -1 ? LIGHTHOUSE_V2_PROVIDER_PRIORITY.length : index;
};
// Stable sort keeps providers outside the priority list in their original order.
const orderedConfigurations = [...connectedConfigurations].sort(
(a, b) => priorityIndex(a.providerType) - priorityIndex(b.providerType),
);
if (tenantDefaultProvider) {
const defaultModel =
tenantConfiguration.defaultModels[tenantDefaultProvider];
if (
defaultModel &&
hasConnectedModel(
connectedConfigurations,
modelsByProvider,
tenantDefaultProvider,
defaultModel,
)
) {
return {
providerType: tenantDefaultProvider,
modelId: defaultModel,
};
}
}
for (const configuration of connectedConfigurations) {
for (const configuration of orderedConfigurations) {
const providerModels = modelsByProvider[configuration.providerType] ?? [];
const savedModel =
tenantConfiguration?.defaultModels[configuration.providerType];
const model =
providerModels.find((candidate) => candidate.id === savedModel) ??
providerModels[0];
if (model) {
return {
providerType: configuration.providerType,
modelId: model.id,
};
}
if (providerModels.length === 0) continue;
// Prefer the provider's remembered model when it is still supported;
// otherwise fall back to the first supported model.
const rememberedModel = providerModels.find(
(model) => model.id === configuration.defaultModel,
);
return {
providerType: configuration.providerType,
modelId: (rememberedModel ?? providerModels[0]).id,
};
}
return null;
}
function hasConnectedModel(
connectedConfigurations: LighthouseV2Configuration[],
modelsByProvider: Record<
LighthouseV2ProviderType,
LighthouseV2SupportedModel[]
>,
providerType: LighthouseV2ProviderType,
modelId: string,
) {
return (
connectedConfigurations.some(
(configuration) => configuration.providerType === providerType,
) &&
(modelsByProvider[providerType] ?? []).some((model) => model.id === modelId)
);
}
function buildModelSelectorGroups(
connectedConfigurations: LighthouseV2Configuration[],
modelsByProvider: Record<
@@ -4,35 +4,43 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { LighthouseV2BusinessContextForm } from "./business-context-form";
const { updateTenantConfigurationMock } = vi.hoisted(() => ({
updateTenantConfigurationMock: vi.fn(),
const { updateConfigurationMock } = vi.hoisted(() => ({
updateConfigurationMock: vi.fn(),
}));
vi.mock("@/app/(prowler)/lighthouse/_actions", () => ({
updateLighthouseV2TenantConfiguration: updateTenantConfigurationMock,
updateLighthouseV2Configuration: updateConfigurationMock,
}));
function tenantConfig(businessContext: string) {
function configuration(businessContext: string) {
return {
id: "tenant-config-1",
id: "config-1",
providerType: "bedrock" as const,
baseUrl: null,
defaultModel: null,
businessContext,
defaultProvider: "" as const,
defaultModels: {},
connected: true,
connectionLastCheckedAt: null,
insertedAt: "2026-06-25T09:00:00Z",
updatedAt: "2026-06-25T10:00:00Z",
};
}
describe("LighthouseV2BusinessContextForm", () => {
beforeEach(() => {
updateTenantConfigurationMock.mockReset();
updateTenantConfigurationMock.mockResolvedValue({
data: tenantConfig("Production context"),
updateConfigurationMock.mockReset();
updateConfigurationMock.mockResolvedValue({
data: configuration("Production context"),
});
});
it("seeds the textarea with the tenant business context and disables save until edited", () => {
it("seeds the textarea with the business context and disables save until edited", () => {
// Given / When
render(
<LighthouseV2BusinessContextForm initialBusinessContext="Production context" />,
<LighthouseV2BusinessContextForm
configurationId="config-1"
initialBusinessContext="Production context"
/>,
);
// Then
@@ -44,10 +52,15 @@ describe("LighthouseV2BusinessContextForm", () => {
).toBeDisabled();
});
it("saves the shared business context to the tenant configuration", async () => {
it("saves the shared business context against the provider configuration", async () => {
// Given
const user = userEvent.setup();
render(<LighthouseV2BusinessContextForm initialBusinessContext="" />);
render(
<LighthouseV2BusinessContextForm
configurationId="config-1"
initialBusinessContext=""
/>,
);
// When
await user.type(
@@ -60,7 +73,7 @@ describe("LighthouseV2BusinessContextForm", () => {
// Then
await waitFor(() =>
expect(updateTenantConfigurationMock).toHaveBeenCalledWith({
expect(updateConfigurationMock).toHaveBeenCalledWith("config-1", {
businessContext: "Production context",
}),
);
@@ -69,7 +82,12 @@ describe("LighthouseV2BusinessContextForm", () => {
it("shows the counter and blocks saving over the character limit", async () => {
// Given
const user = userEvent.setup();
render(<LighthouseV2BusinessContextForm initialBusinessContext="" />);
render(
<LighthouseV2BusinessContextForm
configurationId="config-1"
initialBusinessContext=""
/>,
);
// When: paste in one event instead of 1001 keystrokes
await user.click(
@@ -85,17 +103,22 @@ describe("LighthouseV2BusinessContextForm", () => {
expect(
screen.getByRole("button", { name: "Save business context" }),
).toBeDisabled();
expect(updateTenantConfigurationMock).not.toHaveBeenCalled();
expect(updateConfigurationMock).not.toHaveBeenCalled();
});
it("surfaces the backend reason when the save fails", async () => {
// Given
const user = userEvent.setup();
updateTenantConfigurationMock.mockResolvedValue({
updateConfigurationMock.mockResolvedValue({
error: "No active configuration found for 'bedrock'.",
status: 400,
});
render(<LighthouseV2BusinessContextForm initialBusinessContext="" />);
render(
<LighthouseV2BusinessContextForm
configurationId="config-1"
initialBusinessContext=""
/>,
);
// When
await user.type(
@@ -3,18 +3,20 @@
import { Bot, Loader2, Save } from "lucide-react";
import { useState } from "react";
import { updateLighthouseV2TenantConfiguration } from "@/app/(prowler)/lighthouse/_actions";
import { updateLighthouseV2Configuration } from "@/app/(prowler)/lighthouse/_actions";
import { BUSINESS_CONTEXT_LIMIT } from "@/app/(prowler)/lighthouse/_lib/config";
import { Button } from "@/components/shadcn/button/button";
import { Field, FieldError, FieldLabel } from "@/components/shadcn/field/field";
import { Textarea } from "@/components/shadcn/textarea/textarea";
import { cn } from "@/lib/utils";
// Tenant-wide business context. It is shared across every provider and chat, so
// it is edited once here rather than per provider configuration.
// Shared business context. The backend syncs it across every provider config, so
// it is edited once here against any single configuration rather than per provider.
export function LighthouseV2BusinessContextForm({
configurationId,
initialBusinessContext,
}: {
configurationId: string;
initialBusinessContext: string;
}) {
const [businessContext, setBusinessContext] = useState(
@@ -33,7 +35,7 @@ export function LighthouseV2BusinessContextForm({
setSaving(true);
setError(null);
const result = await updateLighthouseV2TenantConfiguration({
const result = await updateLighthouseV2Configuration(configurationId, {
businessContext,
});
setSaving(false);
@@ -14,14 +14,12 @@ const {
deleteConfigurationMock,
testConnectionMock,
updateConfigurationMock,
updateTenantConfigurationMock,
toastMock,
} = vi.hoisted(() => ({
createConfigurationMock: vi.fn(),
deleteConfigurationMock: vi.fn(),
testConnectionMock: vi.fn(),
updateConfigurationMock: vi.fn(),
updateTenantConfigurationMock: vi.fn(),
toastMock: vi.fn(),
}));
@@ -40,7 +38,6 @@ vi.mock("@/app/(prowler)/lighthouse/_actions", () => ({
deleteLighthouseV2Configuration: deleteConfigurationMock,
testLighthouseV2ConfigurationConnection: testConnectionMock,
updateLighthouseV2Configuration: updateConfigurationMock,
updateLighthouseV2TenantConfiguration: updateTenantConfigurationMock,
}));
const providers: LighthouseV2SupportedProvider[] = [
@@ -55,6 +52,7 @@ const configurations: LighthouseV2Configuration[] = [
providerType: "openai",
baseUrl: null,
defaultModel: "gpt-5.1",
businessContext: "Production context",
connected: true,
connectionLastCheckedAt: "2026-06-24T10:00:00Z",
insertedAt: "2026-06-24T09:00:00Z",
@@ -65,6 +63,7 @@ const configurations: LighthouseV2Configuration[] = [
providerType: "bedrock",
baseUrl: null,
defaultModel: "anthropic.claude-4",
businessContext: "Production context",
connected: false,
connectionLastCheckedAt: "2026-06-23T10:00:00Z",
insertedAt: "2026-06-23T09:00:00Z",
@@ -78,16 +77,7 @@ describe("LighthouseV2ConfigPage", () => {
deleteConfigurationMock.mockReset();
testConnectionMock.mockReset();
updateConfigurationMock.mockReset();
updateTenantConfigurationMock.mockReset();
toastMock.mockReset();
updateTenantConfigurationMock.mockResolvedValue({
data: {
id: "tenant-config-1",
businessContext: "Updated business context",
defaultProvider: "",
defaultModels: {},
},
});
createConfigurationMock.mockResolvedValue({ data: configurations[0] });
deleteConfigurationMock.mockResolvedValue({ data: true });
@@ -167,6 +157,17 @@ describe("LighthouseV2ConfigPage", () => {
).toHaveLength(1);
});
it("hides the business context editor until a provider is configured", () => {
// Given / When: no configurations exist yet
renderPage({ configurations: [] });
// Then: the editor is replaced by a hint and the textarea is absent
expect(
screen.queryByRole("textbox", { name: /Business context/i }),
).not.toBeInTheDocument();
expect(screen.getByText(/Configure a provider first/i)).toBeInTheDocument();
});
it("updates an existing configuration without sending blank credentials or model defaults", async () => {
// Given
const user = userEvent.setup();
@@ -199,6 +200,7 @@ describe("LighthouseV2ConfigPage", () => {
providerType: "openai-compatible",
baseUrl: "https://llm.example.com/v1",
defaultModel: "llama-3.3",
businessContext: "",
connected: null,
connectionLastCheckedAt: null,
insertedAt: "2026-06-24T10:00:00Z",
@@ -328,7 +330,6 @@ function renderPage(
<LighthouseV2ConfigPage
configurations={props?.configurations ?? configurations}
providers={props?.providers ?? providers}
tenantConfiguration={props?.tenantConfiguration}
error={props?.error}
/>,
);
@@ -10,7 +10,6 @@ import {
type LighthouseV2Configuration,
type LighthouseV2ProviderType,
type LighthouseV2SupportedProvider,
type LighthouseV2TenantConfiguration,
} from "@/app/(prowler)/lighthouse/_types";
import { Card } from "@/components/shadcn/card/card";
import { useToast } from "@/components/ui";
@@ -24,14 +23,12 @@ import { LighthouseV2ProviderRail } from "./provider-rail";
interface LighthouseV2ConfigPageProps {
configurations: LighthouseV2Configuration[];
providers: LighthouseV2SupportedProvider[];
tenantConfiguration?: LighthouseV2TenantConfiguration;
error?: string;
}
export function LighthouseV2ConfigPage({
configurations,
providers,
tenantConfiguration,
error,
}: LighthouseV2ConfigPageProps) {
const { toast } = useToast();
@@ -61,6 +58,10 @@ export function LighthouseV2ConfigPage({
}
});
// Business context is shared across every provider (the backend syncs it on
// update), so it is edited once against any single configuration.
const businessContextConfig = localConfigurations[0];
const selectedConfig = localConfigurations.find(
(config) => config.providerType === selectedProvider,
);
@@ -131,9 +132,19 @@ export function LighthouseV2ConfigPage({
aria-label="Lighthouse AI settings"
className="w-full gap-0 overflow-hidden"
>
<LighthouseV2BusinessContextForm
initialBusinessContext={tenantConfiguration?.businessContext ?? ""}
/>
{businessContextConfig ? (
<LighthouseV2BusinessContextForm
configurationId={businessContextConfig.id}
initialBusinessContext={businessContextConfig.businessContext}
/>
) : (
<section
data-slot="lighthouse-v2-business-context-empty"
className="border-border-neutral-secondary text-text-neutral-secondary border-b px-4 py-4 text-sm md:px-5"
>
Configure a provider first to add shared business context.
</section>
)}
<div className="grid min-h-0 flex-1 gap-0 xl:grid-cols-[320px_auto_minmax(0,1fr)]">
<div className="min-w-0 p-4 md:p-5">
+2 -12
View File
@@ -37,6 +37,7 @@ export interface LighthouseV2Configuration {
providerType: LighthouseV2ProviderType;
baseUrl: string | null;
defaultModel: string | null;
businessContext: string;
connected: boolean | null;
connectionLastCheckedAt: string | null;
insertedAt: string;
@@ -52,19 +53,8 @@ export interface LighthouseV2ConfigurationInput {
export interface LighthouseV2ConfigurationUpdateInput {
credentials?: LighthouseV2Credentials;
baseUrl?: string | null;
}
export interface LighthouseV2TenantConfiguration {
id: string;
businessContext: string;
defaultProvider: LighthouseV2ProviderType | "";
defaultModels: Record<string, string>;
}
export interface LighthouseV2TenantConfigurationUpdateInput {
defaultModel?: string | null;
businessContext?: string;
defaultProvider?: LighthouseV2ProviderType | "";
defaultModels?: Record<string, string>;
}
export interface LighthouseV2SupportedProvider {
+1 -9
View File
@@ -9,7 +9,6 @@ import {
getLighthouseV2Messages,
getLighthouseV2Session,
getLighthouseV2SupportedModels,
getLighthouseV2TenantConfiguration,
} from "@/app/(prowler)/lighthouse/_actions";
import { LighthouseV2ChatPage } from "@/app/(prowler)/lighthouse/_components/chat";
import { LighthouseV2NavigationModeSync } from "@/app/(prowler)/lighthouse/_components/navigation";
@@ -37,9 +36,7 @@ export default async function AIChatbot({
typeof params.session === "string" ? params.session : undefined;
if (isCloud()) {
const [configurationsResult, tenantConfigurationResult] = await Promise.all(
[getLighthouseV2Configurations(), getLighthouseV2TenantConfiguration()],
);
const configurationsResult = await getLighthouseV2Configurations();
const configurations =
"data" in configurationsResult ? configurationsResult.data : [];
const connectedConfigurations = configurations.filter(
@@ -92,11 +89,6 @@ export default async function AIChatbot({
key={chatRouteKey}
configurations={configurations}
modelsByProvider={modelsByProvider}
tenantConfiguration={
"data" in tenantConfigurationResult
? tenantConfigurationResult.data
: undefined
}
initialSessionId={activeSessionId}
initialMessages={
"data" in initialMessages ? initialMessages.data : []
+4 -12
View File
@@ -3,7 +3,6 @@ import { Spacer } from "@heroui/spacer";
import {
getLighthouseV2Configurations,
getLighthouseV2SupportedProviders,
getLighthouseV2TenantConfiguration,
} from "@/app/(prowler)/lighthouse/_actions";
import { LighthouseV2ConfigPage } from "@/app/(prowler)/lighthouse/_components/config";
import {
@@ -17,12 +16,10 @@ export const dynamic = "force-dynamic";
export default async function LighthouseSettingsPage() {
if (isCloud()) {
const [configurationsResult, providersResult, tenantConfigurationResult] =
await Promise.all([
getLighthouseV2Configurations(),
getLighthouseV2SupportedProviders(),
getLighthouseV2TenantConfiguration(),
]);
const [configurationsResult, providersResult] = await Promise.all([
getLighthouseV2Configurations(),
getLighthouseV2SupportedProviders(),
]);
const providers = "data" in providersResult ? providersResult.data : [];
const error =
@@ -39,11 +36,6 @@ export default async function LighthouseSettingsPage() {
"data" in configurationsResult ? configurationsResult.data : []
}
providers={providers}
tenantConfiguration={
"data" in tenantConfigurationResult
? tenantConfigurationResult.data
: undefined
}
error={error}
/>
</ContentLayout>