From 56b15e91104c8a6bdfa663957c98372787612cb3 Mon Sep 17 00:00:00 2001 From: alejandrobailo Date: Tue, 30 Jun 2026 10:02:56 +0200 Subject: [PATCH] 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/) 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. --- .../_actions/lighthouse-v2.adapter.test.ts | 74 ++++------- .../_actions/lighthouse-v2.adapter.ts | 36 +----- .../lighthouse/_actions/lighthouse-v2.test.ts | 53 ++++---- .../lighthouse/_actions/lighthouse-v2.ts | 29 ----- .../chat/lighthouse-v2-chat-page.test.tsx | 99 ++++++++------- .../chat/lighthouse-v2-chat-page.tsx | 116 ++++++------------ .../config/business-context-form.test.tsx | 61 ++++++--- .../config/business-context-form.tsx | 10 +- .../config/lighthouse-v2-config-page.test.tsx | 27 ++-- .../config/lighthouse-v2-config-page.tsx | 23 +++- ui/app/(prowler)/lighthouse/_types/config.ts | 14 +-- ui/app/(prowler)/lighthouse/page.tsx | 10 +- ui/app/(prowler)/lighthouse/settings/page.tsx | 16 +-- 13 files changed, 229 insertions(+), 339 deletions(-) diff --git a/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.adapter.test.ts b/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.adapter.test.ts index daf122f9cd..07c8b7c2c8 100644 --- a/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.adapter.test.ts +++ b/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.adapter.test.ts @@ -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[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[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 = { diff --git a/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.adapter.ts b/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.adapter.ts index 78506cd431..be7538011a 100644 --- a/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.adapter.ts +++ b/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.adapter.ts @@ -11,8 +11,6 @@ import { type LighthouseV2SupportedModel, type LighthouseV2SupportedProvider, type LighthouseV2Task, - type LighthouseV2TenantConfiguration, - type LighthouseV2TenantConfigurationUpdateInput, } from "@/app/(prowler)/lighthouse/_types"; export interface JsonApiResource { @@ -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 | 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, -): 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, ): 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, }), }, }; diff --git a/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.test.ts b/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.test.ts index ae625a4890..7f6f2a69fb 100644 --- a/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.test.ts +++ b/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.test.ts @@ -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"); }); }); diff --git a/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.ts b/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.ts index a11f635e30..2cf3c98a7b 100644 --- a/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.ts +++ b/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.ts @@ -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 -> { - return getSingle( - "/lighthouse/configuration", - mapLighthouseV2TenantConfiguration, - ); -} - -export async function updateLighthouseV2TenantConfiguration( - input: LighthouseV2TenantConfigurationUpdateInput, -): Promise> { - 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. diff --git a/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-page.test.tsx b/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-page.test.tsx index 77c2b2df82..9a33af0fb2 100644 --- a/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-page.test.tsx +++ b/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-page.test.tsx @@ -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[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, diff --git a/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-page.tsx b/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-page.tsx index 10575ea83c..703145f2ca 100644 --- a/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-page.tsx +++ b/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-page.tsx @@ -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(initialModelSelection); - const [tenantModelDefaults, setTenantModelDefaults] = useState< - Record - >(tenantConfiguration?.defaultModels ?? {}); const [modelPreferenceSaving, setModelPreferenceSaving] = useState(false); const [activeSessionId, setActiveSessionId] = useState( 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) => { @@ -421,7 +407,7 @@ export function LighthouseV2ChatPage({ input, isStreaming: Boolean(streamState.activeTaskId), modelSelector: ( -
+
, - 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< diff --git a/ui/app/(prowler)/lighthouse/_components/config/business-context-form.test.tsx b/ui/app/(prowler)/lighthouse/_components/config/business-context-form.test.tsx index a97a9ac69e..7a42e8a491 100644 --- a/ui/app/(prowler)/lighthouse/_components/config/business-context-form.test.tsx +++ b/ui/app/(prowler)/lighthouse/_components/config/business-context-form.test.tsx @@ -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( - , + , ); // 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(); + render( + , + ); // 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(); + render( + , + ); // 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(); + render( + , + ); // When await user.type( diff --git a/ui/app/(prowler)/lighthouse/_components/config/business-context-form.tsx b/ui/app/(prowler)/lighthouse/_components/config/business-context-form.tsx index e7d6fc6e4a..7f0202d8ce 100644 --- a/ui/app/(prowler)/lighthouse/_components/config/business-context-form.tsx +++ b/ui/app/(prowler)/lighthouse/_components/config/business-context-form.tsx @@ -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); diff --git a/ui/app/(prowler)/lighthouse/_components/config/lighthouse-v2-config-page.test.tsx b/ui/app/(prowler)/lighthouse/_components/config/lighthouse-v2-config-page.test.tsx index 6820b7e455..b78387bf3a 100644 --- a/ui/app/(prowler)/lighthouse/_components/config/lighthouse-v2-config-page.test.tsx +++ b/ui/app/(prowler)/lighthouse/_components/config/lighthouse-v2-config-page.test.tsx @@ -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( , ); diff --git a/ui/app/(prowler)/lighthouse/_components/config/lighthouse-v2-config-page.tsx b/ui/app/(prowler)/lighthouse/_components/config/lighthouse-v2-config-page.tsx index d338a66f95..561b3bf20d 100644 --- a/ui/app/(prowler)/lighthouse/_components/config/lighthouse-v2-config-page.tsx +++ b/ui/app/(prowler)/lighthouse/_components/config/lighthouse-v2-config-page.tsx @@ -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" > - + {businessContextConfig ? ( + + ) : ( +
+ Configure a provider first to add shared business context. +
+ )}
diff --git a/ui/app/(prowler)/lighthouse/_types/config.ts b/ui/app/(prowler)/lighthouse/_types/config.ts index 1973522550..66e9acd27d 100644 --- a/ui/app/(prowler)/lighthouse/_types/config.ts +++ b/ui/app/(prowler)/lighthouse/_types/config.ts @@ -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; -} - -export interface LighthouseV2TenantConfigurationUpdateInput { + defaultModel?: string | null; businessContext?: string; - defaultProvider?: LighthouseV2ProviderType | ""; - defaultModels?: Record; } export interface LighthouseV2SupportedProvider { diff --git a/ui/app/(prowler)/lighthouse/page.tsx b/ui/app/(prowler)/lighthouse/page.tsx index ed31454b1a..070f7ee7fc 100644 --- a/ui/app/(prowler)/lighthouse/page.tsx +++ b/ui/app/(prowler)/lighthouse/page.tsx @@ -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 : [] diff --git a/ui/app/(prowler)/lighthouse/settings/page.tsx b/ui/app/(prowler)/lighthouse/settings/page.tsx index 3c9b21f41f..2df2e00923 100644 --- a/ui/app/(prowler)/lighthouse/settings/page.tsx +++ b/ui/app/(prowler)/lighthouse/settings/page.tsx @@ -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} />