diff --git a/x-pack/platform/packages/shared/ai-infra/inference-common/index.ts b/x-pack/platform/packages/shared/ai-infra/inference-common/index.ts index e074833558504..ede5696f877e8 100644 --- a/x-pack/platform/packages/shared/ai-infra/inference-common/index.ts +++ b/x-pack/platform/packages/shared/ai-infra/inference-common/index.ts @@ -161,6 +161,7 @@ export { export { INFERENCE_CONNECTORS_INTERNAL_API_PATH, + type ApiInferenceConnector, type InferenceConnectorsApiResponseBody, } from './src/inference_connectors_api'; diff --git a/x-pack/platform/packages/shared/ai-infra/inference-common/src/inference_connectors_api.ts b/x-pack/platform/packages/shared/ai-infra/inference-common/src/inference_connectors_api.ts index e45d808041571..7ae203747827c 100644 --- a/x-pack/platform/packages/shared/ai-infra/inference-common/src/inference_connectors_api.ts +++ b/x-pack/platform/packages/shared/ai-infra/inference-common/src/inference_connectors_api.ts @@ -14,11 +14,19 @@ import type { InferenceConnector } from './connectors/connectors'; export const INFERENCE_CONNECTORS_INTERNAL_API_PATH = '/internal/search_inference_endpoints/connectors' as const; +/** + * Connector entry returned by {@link INFERENCE_CONNECTORS_INTERNAL_API_PATH}. + * `isRecommended` is set server-side for endpoints that a feature recommends when no + * saved-object override is configured. + */ +export interface ApiInferenceConnector extends InferenceConnector { + isRecommended?: boolean; +} + /** * Response body shape for {@link INFERENCE_CONNECTORS_INTERNAL_API_PATH}. */ export interface InferenceConnectorsApiResponseBody { - connectors: InferenceConnector[]; - allConnectors: InferenceConnector[]; + connectors: ApiInferenceConnector[]; soEntryFound: boolean; } diff --git a/x-pack/platform/packages/shared/kbn-inference-connectors/moon.yml b/x-pack/platform/packages/shared/kbn-inference-connectors/moon.yml index e12e5c9ace253..da3347a197e5c 100644 --- a/x-pack/platform/packages/shared/kbn-inference-connectors/moon.yml +++ b/x-pack/platform/packages/shared/kbn-inference-connectors/moon.yml @@ -24,7 +24,6 @@ dependsOn: - '@kbn/core-ui-settings-browser' - '@kbn/connector-schemas' - '@kbn/inference-common' - - '@kbn/management-settings-ids' - '@kbn/alerts-ui-shared' tags: - shared-browser diff --git a/x-pack/platform/packages/shared/kbn-inference-connectors/src/fetch_connectors_for_feature.test.ts b/x-pack/platform/packages/shared/kbn-inference-connectors/src/fetch_connectors_for_feature.test.ts index e51c4d7159eeb..d6a7019a35b1d 100644 --- a/x-pack/platform/packages/shared/kbn-inference-connectors/src/fetch_connectors_for_feature.test.ts +++ b/x-pack/platform/packages/shared/kbn-inference-connectors/src/fetch_connectors_for_feature.test.ts @@ -9,11 +9,11 @@ import type { HttpSetup } from '@kbn/core-http-browser'; import { INFERENCE_CONNECTORS_INTERNAL_API_PATH, InferenceConnectorType, - type InferenceConnector, + type ApiInferenceConnector, } from '@kbn/inference-common'; import { fetchConnectorsForFeature } from './fetch_connectors_for_feature'; -const inferenceConnector = (connectorId: string): InferenceConnector => ({ +const inferenceConnector = (connectorId: string): ApiInferenceConnector => ({ type: InferenceConnectorType.Inference, name: connectorId, connectorId, @@ -24,12 +24,11 @@ const inferenceConnector = (connectorId: string): InferenceConnector => ({ }); describe('fetchConnectorsForFeature', () => { - it('calls the shared internal path with featureId and returns merged connectors with soEntryFound', async () => { - const rec = inferenceConnector('rec'); + it('calls the shared internal path with featureId and returns the response as-is', async () => { + const rec = { ...inferenceConnector('rec'), isRecommended: true }; const other = inferenceConnector('other'); const httpGet = jest.fn().mockResolvedValue({ - connectors: [rec], - allConnectors: [other, rec], + connectors: [rec, other], soEntryFound: false, }); const http = { get: httpGet } as unknown as HttpSetup; @@ -42,15 +41,14 @@ describe('fetchConnectorsForFeature', () => { version: '1', }); expect(result.connectors.map((c) => c.connectorId)).toEqual(['rec', 'other']); + expect(result.connectors[0].isRecommended).toBe(true); expect(result.soEntryFound).toBe(false); }); - it('returns SO-configured connectors unchanged when soEntryFound is true', async () => { + it('returns SO-configured connectors with soEntryFound true', async () => { const a = inferenceConnector('a'); - const b = inferenceConnector('b'); const httpGet = jest.fn().mockResolvedValue({ connectors: [a], - allConnectors: [a, b], soEntryFound: true, }); const http = { get: httpGet } as unknown as HttpSetup; @@ -60,20 +58,4 @@ describe('fetchConnectorsForFeature', () => { expect(result.connectors).toEqual([a]); expect(result.soEntryFound).toBe(true); }); - - it('returns all connectors with default first when no SO and no recommendations', async () => { - const a = inferenceConnector('a'); - const b = inferenceConnector('b'); - const httpGet = jest.fn().mockResolvedValue({ - connectors: [], - allConnectors: [a, b], - soEntryFound: false, - }); - const http = { get: httpGet } as unknown as HttpSetup; - - const result = await fetchConnectorsForFeature(http, 'x'); - - expect(result.connectors).toEqual([a, b]); - expect(result.soEntryFound).toBe(false); - }); }); diff --git a/x-pack/platform/packages/shared/kbn-inference-connectors/src/fetch_connectors_for_feature.ts b/x-pack/platform/packages/shared/kbn-inference-connectors/src/fetch_connectors_for_feature.ts index d2ce9a476ea62..72fc82e5153ea 100644 --- a/x-pack/platform/packages/shared/kbn-inference-connectors/src/fetch_connectors_for_feature.ts +++ b/x-pack/platform/packages/shared/kbn-inference-connectors/src/fetch_connectors_for_feature.ts @@ -6,15 +6,14 @@ */ import type { HttpSetup } from '@kbn/core-http-browser'; -import type { InferenceConnector } from '@kbn/inference-common'; +import type { ApiInferenceConnector } from '@kbn/inference-common'; import { INFERENCE_CONNECTORS_INTERNAL_API_PATH, type InferenceConnectorsApiResponseBody, } from '@kbn/inference-common'; -import { mergeConnectorsFromApiResponse } from './merge_connectors_from_api_response'; export interface FetchConnectorsForFeatureResult { - connectors: InferenceConnector[]; + connectors: ApiInferenceConnector[]; soEntryFound: boolean; } @@ -22,14 +21,13 @@ export const fetchConnectorsForFeature = async ( http: HttpSetup, featureId: string ): Promise => { - const { connectors, allConnectors, soEntryFound } = - await http.get(INFERENCE_CONNECTORS_INTERNAL_API_PATH, { + const { connectors, soEntryFound } = await http.get( + INFERENCE_CONNECTORS_INTERNAL_API_PATH, + { query: { featureId }, version: '1', - }); + } + ); - return { - connectors: mergeConnectorsFromApiResponse(connectors, allConnectors, soEntryFound), - soEntryFound, - }; + return { connectors, soEntryFound }; }; diff --git a/x-pack/platform/packages/shared/kbn-inference-connectors/src/load_connectors.test.ts b/x-pack/platform/packages/shared/kbn-inference-connectors/src/load_connectors.test.ts index 505d5eaaa817f..b85b0bae8af26 100644 --- a/x-pack/platform/packages/shared/kbn-inference-connectors/src/load_connectors.test.ts +++ b/x-pack/platform/packages/shared/kbn-inference-connectors/src/load_connectors.test.ts @@ -6,27 +6,18 @@ */ import type { HttpSetup } from '@kbn/core-http-browser'; -import type { SettingsStart } from '@kbn/core-ui-settings-browser'; -import { InferenceConnectorType, type InferenceConnector } from '@kbn/inference-common'; -import { - GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR, - GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR_DEFAULT_ONLY, -} from '@kbn/management-settings-ids'; +import { InferenceConnectorType, type ApiInferenceConnector } from '@kbn/inference-common'; import { loadConnectors, toAIConnector } from './load_connectors'; import { fetchConnectorsForFeature } from './fetch_connectors_for_feature'; -import { fetchConnectorById } from './fetch_connector_by_id'; jest.mock('./fetch_connectors_for_feature'); const fetchConnectorsForFeatureMock = fetchConnectorsForFeature as jest.MockedFn< typeof fetchConnectorsForFeature >; -jest.mock('./fetch_connector_by_id'); -const fetchConnectorByIdMock = fetchConnectorById as jest.MockedFn; - const createInferenceConnector = ( - overrides: Partial = {} -): InferenceConnector => ({ + overrides: Partial = {} +): ApiInferenceConnector => ({ type: InferenceConnectorType.OpenAI, name: 'Test Connector', connectorId: 'test-connector-id', @@ -37,17 +28,6 @@ const createInferenceConnector = ( ...overrides, }); -const createSettings = (defaultConnectorId?: string, defaultOnly = false): SettingsStart => - ({ - client: { - get: jest.fn((key: string) => { - if (key === GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR) return defaultConnectorId; - if (key === GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR_DEFAULT_ONLY) return defaultOnly; - return undefined; - }), - }, - } as unknown as SettingsStart); - describe('toAIConnector', () => { it('should map InferenceConnector fields to AIConnector shape', () => { const connector = createInferenceConnector({ @@ -93,6 +73,12 @@ describe('toAIConnector', () => { expect(result.isMissingSecrets).toBe(false); }); + it('should propagate isRecommended when set on the API response', () => { + const connector = createInferenceConnector({ isRecommended: true }); + + expect(toAIConnector(connector).isRecommended).toBe(true); + }); + it('should set apiProvider for non-preconfigured OpenAI connectors', () => { const connector = createInferenceConnector({ isPreconfigured: false, @@ -119,154 +105,34 @@ describe('loadConnectors', () => { jest.clearAllMocks(); }); - it('should fetch connectors and map to AIConnector', async () => { + it('fetches connectors for the feature and maps them to AIConnector', async () => { const connector1 = createInferenceConnector({ connectorId: 'c1', name: 'Connector 1' }); - const connector2 = createInferenceConnector({ connectorId: 'c2', name: 'Connector 2' }); + const connector2 = createInferenceConnector({ + connectorId: 'c2', + name: 'Connector 2', + isRecommended: true, + }); fetchConnectorsForFeatureMock.mockResolvedValue({ connectors: [connector1, connector2], soEntryFound: false, }); - const settings = createSettings(); - const result = await loadConnectors({ http, featureId: 'siem_migrations', settings }); + const result = await loadConnectors({ http, featureId: 'siem_migrations' }); expect(fetchConnectorsForFeatureMock).toHaveBeenCalledWith(http, 'siem_migrations'); expect(result).toHaveLength(2); expect(result[0].id).toBe('c1'); expect(result[1].id).toBe('c2'); + expect(result[1].isRecommended).toBe(true); }); - it('should fetch default connector by ID when defaultOnly is true', async () => { - const aiConnector = { - id: 'c1', - name: 'Connector 1', - actionTypeId: InferenceConnectorType.OpenAI, - config: {}, - secrets: {}, - isPreconfigured: false, - isSystemAction: false, - isDeprecated: false, - isConnectorTypeDeprecated: false, - isMissingSecrets: false, - }; - fetchConnectorByIdMock.mockResolvedValue(aiConnector as any); - const settings = createSettings('c1', true); - - const result = await loadConnectors({ http, featureId: 'test', settings }); - - expect(fetchConnectorByIdMock).toHaveBeenCalledWith(http, 'c1'); - expect(fetchConnectorsForFeatureMock).not.toHaveBeenCalled(); - expect(result).toHaveLength(1); - expect(result[0].id).toBe('c1'); - }); - - it('should return empty array when defaultOnly is true but connector is not found', async () => { - fetchConnectorByIdMock.mockResolvedValue(undefined); - const settings = createSettings('missing', true); - - const result = await loadConnectors({ http, featureId: 'test', settings }); - - expect(fetchConnectorByIdMock).toHaveBeenCalledWith(http, 'missing'); - expect(fetchConnectorsForFeatureMock).not.toHaveBeenCalled(); - expect(result).toEqual([]); - }); - - it('should return empty array when defaultOnly is true but no default connector is configured', async () => { - const settings = createSettings(undefined, true); - - const result = await loadConnectors({ http, featureId: 'test', settings }); - - expect(fetchConnectorByIdMock).not.toHaveBeenCalled(); - expect(fetchConnectorsForFeatureMock).not.toHaveBeenCalled(); - expect(result).toEqual([]); - }); - - it('should return default connector first when no SO entry and default is configured', async () => { - const defaultAiConnector = { - id: 'default', - name: 'Default Connector', - actionTypeId: InferenceConnectorType.OpenAI, - config: {}, - secrets: {}, - isPreconfigured: false, - isSystemAction: false, - isDeprecated: false, - isConnectorTypeDeprecated: false, - isMissingSecrets: false, - }; - fetchConnectorByIdMock.mockResolvedValue(defaultAiConnector as any); - const connector1 = createInferenceConnector({ connectorId: 'c1', name: 'Connector 1' }); - const connectorDefault = createInferenceConnector({ - connectorId: 'default', - name: 'Default Connector', - }); - fetchConnectorsForFeatureMock.mockResolvedValue({ - connectors: [connector1, connectorDefault], - soEntryFound: false, - }); - const settings = createSettings('default'); - - const result = await loadConnectors({ http, featureId: 'test', settings }); - - expect(fetchConnectorByIdMock).toHaveBeenCalledWith(http, 'default'); - expect(result).toHaveLength(2); - expect(result[0].id).toBe('default'); - expect(result[1].id).toBe('c1'); - }); - - it('should prepend default connector even if not in the feature list', async () => { - const defaultAiConnector = { - id: 'external', - name: 'External Connector', - actionTypeId: InferenceConnectorType.OpenAI, - config: {}, - secrets: {}, - isPreconfigured: false, - isSystemAction: false, - isDeprecated: false, - isConnectorTypeDeprecated: false, - isMissingSecrets: false, - }; - fetchConnectorByIdMock.mockResolvedValue(defaultAiConnector as any); - const connector1 = createInferenceConnector({ connectorId: 'c1', name: 'Connector 1' }); - fetchConnectorsForFeatureMock.mockResolvedValue({ - connectors: [connector1], - soEntryFound: false, - }); - const settings = createSettings('external'); - - const result = await loadConnectors({ http, featureId: 'test', settings }); - - expect(result).toHaveLength(2); - expect(result[0].id).toBe('external'); - expect(result[1].id).toBe('c1'); - }); - - it('should not reorder when connectors are set via saved object', async () => { - const connector1 = createInferenceConnector({ connectorId: 'c1', name: 'Connector 1' }); - const connector2 = createInferenceConnector({ connectorId: 'c2', name: 'Connector 2' }); - fetchConnectorsForFeatureMock.mockResolvedValue({ - connectors: [connector1, connector2], - soEntryFound: true, - }); - const settings = createSettings('c2'); - - const result = await loadConnectors({ http, featureId: 'test', settings }); - - expect(fetchConnectorByIdMock).not.toHaveBeenCalled(); - expect(result).toHaveLength(2); - expect(result[0].id).toBe('c1'); - expect(result[1].id).toBe('c2'); - }); - - it('should return empty array when no connectors are available', async () => { + it('returns an empty array when no connectors are available', async () => { fetchConnectorsForFeatureMock.mockResolvedValue({ connectors: [], soEntryFound: false, }); - const settings = createSettings(); - const result = await loadConnectors({ http, featureId: 'test', settings }); + const result = await loadConnectors({ http, featureId: 'test' }); expect(result).toEqual([]); }); diff --git a/x-pack/platform/packages/shared/kbn-inference-connectors/src/load_connectors.ts b/x-pack/platform/packages/shared/kbn-inference-connectors/src/load_connectors.ts index 6a87b78654a33..eb37390c3277d 100644 --- a/x-pack/platform/packages/shared/kbn-inference-connectors/src/load_connectors.ts +++ b/x-pack/platform/packages/shared/kbn-inference-connectors/src/load_connectors.ts @@ -7,19 +7,12 @@ import type { HttpSetup } from '@kbn/core-http-browser'; import type { SettingsStart } from '@kbn/core-ui-settings-browser'; -import type { InferenceConnector } from '@kbn/inference-common'; -import { - GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR, - GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR_DEFAULT_ONLY, -} from '@kbn/management-settings-ids'; -import { fetchConnectorById } from './fetch_connector_by_id'; +import type { ApiInferenceConnector } from '@kbn/inference-common'; import { fetchConnectorsForFeature } from './fetch_connectors_for_feature'; import { isOpenAiProviderType } from './openai_provider_type_guard'; import type { AIConnector } from './types'; -type InferenceConnectorFromApi = InferenceConnector & { isRecommended?: boolean }; - -export const toAIConnector = (connector: InferenceConnectorFromApi): AIConnector => ({ +export const toAIConnector = (connector: ApiInferenceConnector): AIConnector => ({ id: connector.connectorId, name: connector.name, actionTypeId: connector.type, @@ -41,49 +34,21 @@ export const toAIConnector = (connector: InferenceConnectorFromApi): AIConnector }); /** - * Fetches AI connectors for a given feature, maps them to {@link AIConnector}, - * and applies the default-connector UI settings filter. + * Fetches AI connectors for a given feature from the search_inference_endpoints backend + * and maps them to {@link AIConnector}. The backend route applies feature resolution, + * default-connector UI settings, and recommended-endpoint flagging. * - * When the "default connector only" setting is active and a default connector - * ID is configured, the connector is retrieved directly by ID rather than - * filtered from the feature connector list. + * @param settings - Deprecated; no longer read. Default-connector UI settings are applied + * server-side. Kept for call-site compatibility. */ export const loadConnectors = async ({ http, featureId, - settings, }: { http: HttpSetup; featureId: string; - settings: SettingsStart; + settings?: SettingsStart; }): Promise => { - const defaultConnectorId = settings.client.get(GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR); - const defaultConnectorOnly = settings.client.get( - GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR_DEFAULT_ONLY, - false - ); - - if (defaultConnectorOnly) { - if (!defaultConnectorId) { - return []; - } - const connector = await fetchConnectorById(http, defaultConnectorId); - if (connector) { - return [connector]; - } else { - return []; - } - } - - const { connectors, soEntryFound } = await fetchConnectorsForFeature(http, featureId); - const aiConnectors = connectors.map(toAIConnector); - - if (!soEntryFound && defaultConnectorId) { - const defaultConnector = await fetchConnectorById(http, defaultConnectorId); - if (defaultConnector) { - return [defaultConnector, ...aiConnectors.filter((c) => c.id !== defaultConnectorId)]; - } - } - - return aiConnectors; + const { connectors } = await fetchConnectorsForFeature(http, featureId); + return connectors.map(toAIConnector); }; diff --git a/x-pack/platform/packages/shared/kbn-inference-connectors/src/merge_connectors_from_api_response.ts b/x-pack/platform/packages/shared/kbn-inference-connectors/src/merge_connectors_from_api_response.ts deleted file mode 100644 index 311287d11a81c..0000000000000 --- a/x-pack/platform/packages/shared/kbn-inference-connectors/src/merge_connectors_from_api_response.ts +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { InferenceConnector } from '@kbn/inference-common'; -import { defaultInferenceEndpoints } from '@kbn/inference-common'; - -/** - * Turns the internal inference-connectors API payload into the ordered list the UI consumes. - * - * When `soEntryFound` is true, `connectors` are admin-configured SO overrides and take - * precedence — they are returned as-is. - * - * When `soEntryFound` is false and `connectors` is non-empty, those are recommended - * endpoints. They are listed first; remaining entries from `allConnectors` follow - * without duplicates. - * - * When `soEntryFound` is false and `connectors` is empty, the full catalog is returned - * with the platform default chat-completion endpoint moved to the front. - */ -export const mergeConnectorsFromApiResponse = ( - connectors: InferenceConnector[], - allConnectors: InferenceConnector[], - soEntryFound: boolean -): InferenceConnector[] => { - if (soEntryFound) { - return connectors; - } - - if (connectors.length > 0) { - const recommendedIds = new Set(connectors.map((c) => c.connectorId)); - const otherConnectors = allConnectors.filter((c) => !recommendedIds.has(c.connectorId)); - return [...connectors, ...otherConnectors]; - } - - const defaultId = defaultInferenceEndpoints.KIBANA_DEFAULT_CHAT_COMPLETION; - const defaultIndex = allConnectors.findIndex((c) => c.connectorId === defaultId); - if (defaultIndex > 0) { - const reordered = [...allConnectors]; - const [defaultConnector] = reordered.splice(defaultIndex, 1); - reordered.unshift(defaultConnector); - return reordered; - } - - return allConnectors; -}; diff --git a/x-pack/platform/packages/shared/kbn-inference-connectors/src/use_load_connectors.ts b/x-pack/platform/packages/shared/kbn-inference-connectors/src/use_load_connectors.ts index 94f64dc07e965..d05870adcb87d 100644 --- a/x-pack/platform/packages/shared/kbn-inference-connectors/src/use_load_connectors.ts +++ b/x-pack/platform/packages/shared/kbn-inference-connectors/src/use_load_connectors.ts @@ -11,12 +11,7 @@ import { useQuery } from '@kbn/react-query'; import type { IHttpFetchError, HttpSetup } from '@kbn/core-http-browser'; import type { IToasts } from '@kbn/core-notifications-browser'; import type { SettingsStart } from '@kbn/core-ui-settings-browser'; -import { - GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR, - GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR_DEFAULT_ONLY, -} from '@kbn/management-settings-ids'; import { i18n } from '@kbn/i18n'; -import { fetchConnectorById } from './fetch_connector_by_id'; import { fetchConnectorsForFeature } from './fetch_connectors_for_feature'; import { toAIConnector } from './load_connectors'; import type { AIConnector } from './types'; @@ -38,7 +33,12 @@ export interface UseLoadConnectorsProps { * Passed to the search_inference_endpoints API to resolve feature-specific endpoints. */ featureId: string; - settings: SettingsStart; + /** + * @deprecated No longer read by the hook. Default-connector UI settings are now applied + * server-side by the search_inference_endpoints connectors route. Kept for call-site + * compatibility and will be removed in a follow-up. + */ + settings?: SettingsStart; } export type UseLoadConnectorsResult = UseQueryResult & { @@ -49,42 +49,14 @@ export const useLoadConnectors = ({ http, toasts, featureId, - settings, }: UseLoadConnectorsProps): UseLoadConnectorsResult => { const [soEntryFound, setSoEntryFound] = useState(false); const query = useQuery( [...QUERY_KEY, featureId], async () => { - const defaultConnectorId = settings.client.get(GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR); - const defaultConnectorOnly = settings.client.get( - GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR_DEFAULT_ONLY, - false - ); - - if (defaultConnectorOnly) { - if (!defaultConnectorId) { - return []; - } - const connector = await fetchConnectorById(http, defaultConnectorId); - if (connector) { - return [connector]; - } else { - return []; - } - } - const result = await fetchConnectorsForFeature(http, featureId); setSoEntryFound(result.soEntryFound); - const aiConnectors = result.connectors.map(toAIConnector); - - if (!result.soEntryFound && defaultConnectorId) { - const defaultConnector = await fetchConnectorById(http, defaultConnectorId); - if (defaultConnector) { - return [defaultConnector, ...aiConnectors.filter((c) => c.id !== defaultConnectorId)]; - } - } - - return aiConnectors; + return result.connectors.map(toAIConnector); }, { retry: false, diff --git a/x-pack/platform/packages/shared/kbn-inference-connectors/tsconfig.json b/x-pack/platform/packages/shared/kbn-inference-connectors/tsconfig.json index 9bf5f00da017f..4fb32da6ddf84 100644 --- a/x-pack/platform/packages/shared/kbn-inference-connectors/tsconfig.json +++ b/x-pack/platform/packages/shared/kbn-inference-connectors/tsconfig.json @@ -24,7 +24,6 @@ "@kbn/core-ui-settings-browser", "@kbn/connector-schemas", "@kbn/inference-common", - "@kbn/management-settings-ids", "@kbn/alerts-ui-shared" ] } diff --git a/x-pack/platform/plugins/shared/automatic_import/test/scout_automatic_import/ui/fixtures/mock_data.ts b/x-pack/platform/plugins/shared/automatic_import/test/scout_automatic_import/ui/fixtures/mock_data.ts index 7bb4589d1cd05..ad1da94f65893 100644 --- a/x-pack/platform/plugins/shared/automatic_import/test/scout_automatic_import/ui/fixtures/mock_data.ts +++ b/x-pack/platform/plugins/shared/automatic_import/test/scout_automatic_import/ui/fixtures/mock_data.ts @@ -21,12 +21,10 @@ export const MOCK_CONNECTOR = { export const CONNECTORS_WITH_ONE = { connectors: [MOCK_CONNECTOR], - allConnectors: [MOCK_CONNECTOR], soEntryFound: false, }; export const CONNECTORS_EMPTY_RESPONSE = { connectors: [], - allConnectors: [], soEntryFound: false, }; diff --git a/x-pack/platform/packages/shared/kbn-inference-connectors/src/merge_connectors_from_api_response.test.ts b/x-pack/platform/plugins/shared/search_inference_endpoints/server/lib/merge_connectors.test.ts similarity index 67% rename from x-pack/platform/packages/shared/kbn-inference-connectors/src/merge_connectors_from_api_response.test.ts rename to x-pack/platform/plugins/shared/search_inference_endpoints/server/lib/merge_connectors.test.ts index 98ce9cf5a7497..74496be4848b9 100644 --- a/x-pack/platform/packages/shared/kbn-inference-connectors/src/merge_connectors_from_api_response.test.ts +++ b/x-pack/platform/plugins/shared/search_inference_endpoints/server/lib/merge_connectors.test.ts @@ -10,7 +10,7 @@ import { InferenceConnectorType, defaultInferenceEndpoints, } from '@kbn/inference-common'; -import { mergeConnectorsFromApiResponse } from './merge_connectors_from_api_response'; +import { mergeConnectors } from './merge_connectors'; const inferenceConnector = (connectorId: string): InferenceConnector => ({ type: InferenceConnectorType.Inference, @@ -24,37 +24,42 @@ const inferenceConnector = (connectorId: string): InferenceConnector => ({ const DEFAULT_CHAT_ID = defaultInferenceEndpoints.KIBANA_DEFAULT_CHAT_COMPLETION; -describe('mergeConnectorsFromApiResponse', () => { +describe('mergeConnectors', () => { describe('when soEntryFound is true (admin SO override)', () => { - it('returns only the SO-configured connectors', () => { + it('returns only the SO-configured connectors without isRecommended', () => { const soA = inferenceConnector('so-a'); const soB = inferenceConnector('so-b'); const all = [soA, soB, inferenceConnector('other')]; - const result = mergeConnectorsFromApiResponse([soA, soB], all, true); + const result = mergeConnectors([soA, soB], all, true); expect(result).toEqual([soA, soB]); + expect(result.every((c) => c.isRecommended === undefined)).toBe(true); }); - it('returns empty array when SO explicitly lists no endpoints', () => { + it('returns an empty array when SO explicitly lists no endpoints', () => { const all = [inferenceConnector('a'), inferenceConnector('b')]; - const result = mergeConnectorsFromApiResponse([], all, true); + const result = mergeConnectors([], all, true); expect(result).toEqual([]); }); }); - describe('when soEntryFound is false and connectors is non-empty (recommended endpoints)', () => { - it('lists recommended connectors first, then the rest of the catalog without duplicates', () => { + describe('when soEntryFound is false and feature endpoints are non-empty (recommended endpoints)', () => { + it('flags recommended endpoints, lists them first, and appends the rest of the catalog without duplicates', () => { const recA = inferenceConnector('rec-a'); const recB = inferenceConnector('rec-b'); const other = inferenceConnector('other'); const all = [other, recA, recB, inferenceConnector('tail')]; - const result = mergeConnectorsFromApiResponse([recA, recB], all, false); + const result = mergeConnectors([recA, recB], all, false); expect(result.map((c) => c.connectorId)).toEqual(['rec-a', 'rec-b', 'other', 'tail']); + expect(result[0].isRecommended).toBe(true); + expect(result[1].isRecommended).toBe(true); + expect(result[2].isRecommended).toBeUndefined(); + expect(result[3].isRecommended).toBeUndefined(); }); it('preserves server order within the recommended list and within the trailing catalog', () => { @@ -62,7 +67,7 @@ describe('mergeConnectorsFromApiResponse', () => { const second = inferenceConnector('a-second'); const all = [inferenceConnector('c'), inferenceConnector('b')]; - const result = mergeConnectorsFromApiResponse([first, second], all, false); + const result = mergeConnectors([first, second], all, false); expect(result.map((c) => c.connectorId)).toEqual(['z-first', 'a-second', 'c', 'b']); }); @@ -70,19 +75,19 @@ describe('mergeConnectorsFromApiResponse', () => { it('returns only the recommended list when allConnectors is empty', () => { const only = inferenceConnector('solo'); - const result = mergeConnectorsFromApiResponse([only], [], false); + const result = mergeConnectors([only], [], false); - expect(result).toEqual([only]); + expect(result).toEqual([{ ...only, isRecommended: true }]); }); }); - describe('when soEntryFound is false and connectors is empty (no recommendations)', () => { + describe('when soEntryFound is false and feature endpoints are empty (no recommendations)', () => { it('moves the platform default chat-completion endpoint to the front', () => { const a = inferenceConnector('a'); const def = inferenceConnector(DEFAULT_CHAT_ID); const b = inferenceConnector('b'); - const result = mergeConnectorsFromApiResponse([], [a, b, def], false); + const result = mergeConnectors([], [a, b, def], false); expect(result[0].connectorId).toBe(DEFAULT_CHAT_ID); expect(result.map((c) => c.connectorId)).toEqual([DEFAULT_CHAT_ID, 'a', 'b']); @@ -92,7 +97,7 @@ describe('mergeConnectorsFromApiResponse', () => { const def = inferenceConnector(DEFAULT_CHAT_ID); const rest = [inferenceConnector('x'), inferenceConnector('y')]; - const result = mergeConnectorsFromApiResponse([], [def, ...rest], false); + const result = mergeConnectors([], [def, ...rest], false); expect(result.map((c) => c.connectorId)).toEqual([DEFAULT_CHAT_ID, 'x', 'y']); }); @@ -100,7 +105,7 @@ describe('mergeConnectorsFromApiResponse', () => { it('leaves order unchanged when the default id is not in the list', () => { const list = [inferenceConnector('x'), inferenceConnector('y')]; - const result = mergeConnectorsFromApiResponse([], list, false); + const result = mergeConnectors([], list, false); expect(result).toEqual(list); }); @@ -108,7 +113,7 @@ describe('mergeConnectorsFromApiResponse', () => { it('returns allConnectors as-is when list is only the default endpoint', () => { const def = inferenceConnector(DEFAULT_CHAT_ID); - const result = mergeConnectorsFromApiResponse([], [def], false); + const result = mergeConnectors([], [def], false); expect(result).toEqual([def]); }); diff --git a/x-pack/platform/plugins/shared/search_inference_endpoints/server/lib/merge_connectors.ts b/x-pack/platform/plugins/shared/search_inference_endpoints/server/lib/merge_connectors.ts new file mode 100644 index 0000000000000..dc1369978ebfc --- /dev/null +++ b/x-pack/platform/plugins/shared/search_inference_endpoints/server/lib/merge_connectors.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { InferenceConnector } from '@kbn/inference-common'; +import { defaultInferenceEndpoints } from '@kbn/inference-common'; + +export interface ApiInferenceConnector extends InferenceConnector { + isRecommended?: boolean; +} + +/** + * Produces the ordered connector list the UI consumes. + * + * - When `soEntryFound` is true, `featureEndpoints` are admin-configured SO overrides and + * take precedence; they are returned as-is (no recommended flag). + * - When `soEntryFound` is false and `featureEndpoints` is non-empty, those are recommended + * endpoints. They are marked with `isRecommended: true`, listed first, then the remaining + * entries from `allConnectors` follow without duplicates. + * - When `soEntryFound` is false and `featureEndpoints` is empty, the full catalog is + * returned with the platform default chat-completion endpoint moved to the front. + */ +export const mergeConnectors = ( + featureEndpoints: InferenceConnector[], + allConnectors: InferenceConnector[], + soEntryFound: boolean +): ApiInferenceConnector[] => { + if (soEntryFound) { + return featureEndpoints; + } + + if (featureEndpoints.length > 0) { + const recommendedIds = new Set(featureEndpoints.map((c) => c.connectorId)); + const recommended: ApiInferenceConnector[] = featureEndpoints.map((c) => ({ + ...c, + isRecommended: true, + })); + const otherConnectors = allConnectors.filter((c) => !recommendedIds.has(c.connectorId)); + return [...recommended, ...otherConnectors]; + } + + const defaultId = defaultInferenceEndpoints.KIBANA_DEFAULT_CHAT_COMPLETION; + const defaultIndex = allConnectors.findIndex((c) => c.connectorId === defaultId); + if (defaultIndex > 0) { + const reordered = [...allConnectors]; + const [defaultConnector] = reordered.splice(defaultIndex, 1); + reordered.unshift(defaultConnector); + return reordered; + } + + return allConnectors; +}; diff --git a/x-pack/platform/plugins/shared/search_inference_endpoints/server/routes.ts b/x-pack/platform/plugins/shared/search_inference_endpoints/server/routes.ts index 2974217b046f6..de07a77f5d418 100644 --- a/x-pack/platform/plugins/shared/search_inference_endpoints/server/routes.ts +++ b/x-pack/platform/plugins/shared/search_inference_endpoints/server/routes.ts @@ -37,7 +37,13 @@ export function defineRoutes({ }) { defineInferenceSettingsRoutes({ logger, router, featureRegistry, getConnectorById }); defineInferenceFeaturesRoutes({ logger, router, featureRegistry }); - defineInferenceConnectorsRoute({ logger, router, getForFeature, getConnectorList }); + defineInferenceConnectorsRoute({ + logger, + router, + getForFeature, + getConnectorList, + getConnectorById, + }); router.get( { path: APIRoutes.GET_INFERENCE_ENDPOINTS, diff --git a/x-pack/platform/plugins/shared/search_inference_endpoints/server/routes/inference_connectors.test.ts b/x-pack/platform/plugins/shared/search_inference_endpoints/server/routes/inference_connectors.test.ts index 0e6b6f52ff728..1ccb35a3eb508 100644 --- a/x-pack/platform/plugins/shared/search_inference_endpoints/server/routes/inference_connectors.test.ts +++ b/x-pack/platform/plugins/shared/search_inference_endpoints/server/routes/inference_connectors.test.ts @@ -8,6 +8,10 @@ import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; import type { RequestHandlerContext } from '@kbn/core/server'; import { InferenceConnectorType } from '@kbn/inference-common'; +import { + GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR, + GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR_DEFAULT_ONLY, +} from '@kbn/management-settings-ids'; import { MockRouter } from '../../__mocks__/router.mock'; import { ROUTE_VERSIONS } from '../../common/constants'; import { APIRoutes } from '../../common/types'; @@ -23,20 +27,40 @@ const inferenceConnector = (connectorId: string) => ({ isPreconfigured: false, }); +interface SettingsValues { + defaultConnectorId?: string; + defaultConnectorOnly?: boolean; +} + +const createContext = ({ + defaultConnectorId, + defaultConnectorOnly, +}: SettingsValues = {}): jest.Mocked => + ({ + core: Promise.resolve({ + uiSettings: { + client: { + get: jest.fn(async (key: string) => { + if (key === GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR) return defaultConnectorId; + if (key === GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR_DEFAULT_ONLY) + return defaultConnectorOnly ?? false; + return undefined; + }), + }, + }, + }), + } as unknown as jest.Mocked); + describe('GET /internal/search_inference_endpoints/connectors', () => { const mockLogger = loggingSystemMock.createLogger().get(); let mockRouter: MockRouter; - let context: jest.Mocked; let getForFeature: jest.Mock; let getConnectorList: jest.Mock; + let getConnectorById: jest.Mock; - beforeEach(() => { - jest.clearAllMocks(); - context = {} as jest.Mocked; - getForFeature = jest.fn(); - getConnectorList = jest.fn(); + const registerRoute = (settings: SettingsValues = {}) => { mockRouter = new MockRouter({ - context, + context: createContext(settings), method: 'get', path: APIRoutes.GET_INFERENCE_CONNECTORS, version: ROUTE_VERSIONS.v1, @@ -46,18 +70,26 @@ describe('GET /internal/search_inference_endpoints/connectors', () => { router: mockRouter.router, getForFeature, getConnectorList, + getConnectorById, }); + }; + + beforeEach(() => { + jest.clearAllMocks(); + getForFeature = jest.fn(); + getConnectorList = jest.fn(); + getConnectorById = jest.fn(); }); - it('returns SO endpoints when soEntryFound is true', async () => { + it('returns SO-configured endpoints as-is when soEntryFound is true', async () => { const resolved = inferenceConnector('feature-ep'); - const fullCatalog = [resolved, inferenceConnector('other')]; getForFeature.mockResolvedValue({ endpoints: [resolved], warnings: [], soEntryFound: true, }); - getConnectorList.mockResolvedValue(fullCatalog); + getConnectorList.mockResolvedValue([resolved, inferenceConnector('other')]); + registerRoute(); await mockRouter.callRoute({ query: { featureId: 'my_feature' } }); @@ -66,90 +98,227 @@ describe('GET /internal/search_inference_endpoints/connectors', () => { expect(mockRouter.response.ok).toHaveBeenCalledWith({ body: { connectors: [resolved], - allConnectors: fullCatalog, soEntryFound: true, }, }); }); - it('returns recommended endpoints with soEntryFound false when no SO override', async () => { + it('marks recommended endpoints with isRecommended and appends the rest of the catalog', async () => { const recommended = inferenceConnector('rec-ep'); - const fullCatalog = [recommended, inferenceConnector('noise')]; + const other = inferenceConnector('noise'); getForFeature.mockResolvedValue({ endpoints: [recommended], warnings: [], soEntryFound: false, }); - getConnectorList.mockResolvedValue(fullCatalog); + getConnectorList.mockResolvedValue([recommended, other]); + registerRoute(); await mockRouter.callRoute({ query: { featureId: 'my_feature' } }); expect(mockRouter.response.ok).toHaveBeenCalledWith({ body: { - connectors: [recommended], - allConnectors: fullCatalog, + connectors: [{ ...recommended, isRecommended: true }, other], soEntryFound: false, }, }); }); - it('returns empty connectors with soEntryFound false when no SO and no recommendations', async () => { - const fullCatalog = [inferenceConnector('a'), inferenceConnector('b')]; + it('returns the catalog alone when there are no recommendations or SO override', async () => { + const a = inferenceConnector('a'); + const b = inferenceConnector('b'); getForFeature.mockResolvedValue({ endpoints: [], warnings: [], soEntryFound: false, }); - getConnectorList.mockResolvedValue(fullCatalog); + getConnectorList.mockResolvedValue([a, b]); + registerRoute(); await mockRouter.callRoute({ query: { featureId: 'my_feature' } }); expect(mockRouter.response.ok).toHaveBeenCalledWith({ body: { - connectors: [], - allConnectors: fullCatalog, + connectors: [a, b], soEntryFound: false, }, }); }); - it('returns empty connectors with soEntryFound true when SO explicitly lists empty', async () => { - const fullCatalog = [inferenceConnector('a'), inferenceConnector('b')]; + it('returns an empty list when SO explicitly configures no endpoints', async () => { getForFeature.mockResolvedValue({ endpoints: [], warnings: [], soEntryFound: true, }); - getConnectorList.mockResolvedValue(fullCatalog); + getConnectorList.mockResolvedValue([inferenceConnector('a')]); + registerRoute(); await mockRouter.callRoute({ query: { featureId: 'my_feature' } }); expect(mockRouter.response.ok).toHaveBeenCalledWith({ body: { connectors: [], - allConnectors: fullCatalog, soEntryFound: true, }, }); }); - it('returns allConnectors when getForFeature fails', async () => { - const fullCatalog = [inferenceConnector('a'), inferenceConnector('b')]; - getForFeature.mockRejectedValue(new Error('SO unavailable')); - getConnectorList.mockResolvedValue(fullCatalog); + it('returns only the default connector when defaultConnectorOnly is set', async () => { + const defaultConnector = inferenceConnector('default-id'); + getConnectorById.mockResolvedValue(defaultConnector); + registerRoute({ defaultConnectorId: 'default-id', defaultConnectorOnly: true }); + + await mockRouter.callRoute({ query: { featureId: 'my_feature' } }); + + expect(getConnectorById).toHaveBeenCalledWith('default-id', expect.anything()); + expect(getForFeature).not.toHaveBeenCalled(); + expect(getConnectorList).not.toHaveBeenCalled(); + expect(mockRouter.response.ok).toHaveBeenCalledWith({ + body: { + connectors: [defaultConnector], + soEntryFound: false, + }, + }); + }); + + it('returns an empty list when defaultConnectorOnly is set but no default is configured', async () => { + registerRoute({ defaultConnectorOnly: true }); await mockRouter.callRoute({ query: { featureId: 'my_feature' } }); + expect(getConnectorById).not.toHaveBeenCalled(); + expect(getForFeature).not.toHaveBeenCalled(); + expect(getConnectorList).not.toHaveBeenCalled(); expect(mockRouter.response.ok).toHaveBeenCalledWith({ body: { connectors: [], - allConnectors: fullCatalog, soEntryFound: false, }, }); }); - it('returns feature endpoints when getConnectorList fails', async () => { + it('returns an empty list when defaultConnectorOnly is set but the default connector lookup fails', async () => { + getConnectorById.mockRejectedValue(new Error('Connector not found')); + registerRoute({ defaultConnectorId: 'missing', defaultConnectorOnly: true }); + + await mockRouter.callRoute({ query: { featureId: 'my_feature' } }); + + expect(mockRouter.response.ok).toHaveBeenCalledWith({ + body: { + connectors: [], + soEntryFound: false, + }, + }); + }); + + it('prepends the default connector when soEntryFound is false and a default is configured', async () => { + const recommended = inferenceConnector('rec'); + const other = inferenceConnector('other'); + const defaultConnector = inferenceConnector('default'); + getForFeature.mockResolvedValue({ + endpoints: [recommended], + warnings: [], + soEntryFound: false, + }); + getConnectorList.mockResolvedValue([recommended, other]); + getConnectorById.mockResolvedValue(defaultConnector); + registerRoute({ defaultConnectorId: 'default' }); + + await mockRouter.callRoute({ query: { featureId: 'my_feature' } }); + + expect(getConnectorById).toHaveBeenCalledWith('default', expect.anything()); + expect(mockRouter.response.ok).toHaveBeenCalledWith({ + body: { + connectors: [defaultConnector, { ...recommended, isRecommended: true }, other], + soEntryFound: false, + }, + }); + }); + + it('replaces an existing entry when the default connector is already in the merged list', async () => { + const recommended = inferenceConnector('rec'); + const defaultInCatalog = inferenceConnector('default'); + const fullCatalog = [recommended, defaultInCatalog]; + getForFeature.mockResolvedValue({ + endpoints: [recommended], + warnings: [], + soEntryFound: false, + }); + getConnectorList.mockResolvedValue(fullCatalog); + getConnectorById.mockResolvedValue(defaultInCatalog); + registerRoute({ defaultConnectorId: 'default' }); + + await mockRouter.callRoute({ query: { featureId: 'my_feature' } }); + + expect(mockRouter.response.ok).toHaveBeenCalledWith({ + body: { + connectors: [defaultInCatalog, { ...recommended, isRecommended: true }], + soEntryFound: false, + }, + }); + }); + + it('ignores the default connector when soEntryFound is true', async () => { + const resolved = inferenceConnector('feature-ep'); + getForFeature.mockResolvedValue({ + endpoints: [resolved], + warnings: [], + soEntryFound: true, + }); + getConnectorList.mockResolvedValue([resolved]); + registerRoute({ defaultConnectorId: 'default' }); + + await mockRouter.callRoute({ query: { featureId: 'my_feature' } }); + + expect(getConnectorById).not.toHaveBeenCalled(); + expect(mockRouter.response.ok).toHaveBeenCalledWith({ + body: { + connectors: [resolved], + soEntryFound: true, + }, + }); + }); + + it('returns the merged list without the default connector when its lookup fails', async () => { + const recommended = inferenceConnector('rec'); + getForFeature.mockResolvedValue({ + endpoints: [recommended], + warnings: [], + soEntryFound: false, + }); + getConnectorList.mockResolvedValue([recommended]); + getConnectorById.mockRejectedValue(new Error('Default connector unavailable')); + registerRoute({ defaultConnectorId: 'default' }); + + await mockRouter.callRoute({ query: { featureId: 'my_feature' } }); + + expect(mockRouter.response.ok).toHaveBeenCalledWith({ + body: { + connectors: [{ ...recommended, isRecommended: true }], + soEntryFound: false, + }, + }); + }); + + it('falls back to the catalog when getForFeature fails', async () => { + const a = inferenceConnector('a'); + const b = inferenceConnector('b'); + getForFeature.mockRejectedValue(new Error('SO unavailable')); + getConnectorList.mockResolvedValue([a, b]); + registerRoute(); + + await mockRouter.callRoute({ query: { featureId: 'my_feature' } }); + + expect(mockRouter.response.ok).toHaveBeenCalledWith({ + body: { + connectors: [a, b], + soEntryFound: false, + }, + }); + }); + + it('falls back to the feature endpoints when getConnectorList fails', async () => { const resolved = inferenceConnector('feature-ep'); getForFeature.mockResolvedValue({ endpoints: [resolved], @@ -157,28 +326,28 @@ describe('GET /internal/search_inference_endpoints/connectors', () => { soEntryFound: true, }); getConnectorList.mockRejectedValue(new Error('Inference API unavailable')); + registerRoute(); await mockRouter.callRoute({ query: { featureId: 'my_feature' } }); expect(mockRouter.response.ok).toHaveBeenCalledWith({ body: { connectors: [resolved], - allConnectors: [], soEntryFound: true, }, }); }); - it('returns empty result when both getForFeature and getConnectorList fail', async () => { + it('returns an empty result when both getForFeature and getConnectorList fail', async () => { getForFeature.mockRejectedValue(new Error('SO unavailable')); getConnectorList.mockRejectedValue(new Error('Inference API unavailable')); + registerRoute(); await mockRouter.callRoute({ query: { featureId: 'my_feature' } }); expect(mockRouter.response.ok).toHaveBeenCalledWith({ body: { connectors: [], - allConnectors: [], soEntryFound: false, }, }); diff --git a/x-pack/platform/plugins/shared/search_inference_endpoints/server/routes/inference_connectors.ts b/x-pack/platform/plugins/shared/search_inference_endpoints/server/routes/inference_connectors.ts index ecfc4529ad625..71aba95c69840 100644 --- a/x-pack/platform/plugins/shared/search_inference_endpoints/server/routes/inference_connectors.ts +++ b/x-pack/platform/plugins/shared/search_inference_endpoints/server/routes/inference_connectors.ts @@ -8,21 +8,28 @@ import type { IRouter, KibanaRequest, Logger } from '@kbn/core/server'; import { schema } from '@kbn/config-schema'; import type { InferenceConnector } from '@kbn/inference-common'; +import { + GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR, + GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR_DEFAULT_ONLY, +} from '@kbn/management-settings-ids'; import { APIRoutes } from '../../common/types'; import { ROUTE_VERSIONS } from '../../common/constants'; import type { ResolvedInferenceEndpoints } from '../types'; import { errorHandler } from '../utils/error_handler'; +import { mergeConnectors, type ApiInferenceConnector } from '../lib/merge_connectors'; export const defineInferenceConnectorsRoute = ({ logger, router, getForFeature, getConnectorList, + getConnectorById, }: { logger: Logger; router: IRouter; getForFeature: (featureId: string, request: KibanaRequest) => Promise; getConnectorList: (request: KibanaRequest) => Promise; + getConnectorById: (id: string, request: KibanaRequest) => Promise; }) => { router.versioned .get({ @@ -52,8 +59,35 @@ export const defineInferenceConnectorsRoute = ({ }, version: ROUTE_VERSIONS.v1, }, - errorHandler(logger)(async (_context, request, response) => { + errorHandler(logger)(async (context, request, response) => { const { featureId } = request.query; + const uiSettingsClient = (await context.core).uiSettings.client; + const [defaultConnectorId, defaultConnectorOnly] = await Promise.all([ + uiSettingsClient.get(GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR), + uiSettingsClient.get(GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR_DEFAULT_ONLY), + ]); + + const fetchConnectorById = async (id: string): Promise => { + try { + return await getConnectorById(id, request); + } catch (e) { + logger.warn(`Failed to load default connector "${id}": ${e.message}`); + return undefined; + } + }; + + if (defaultConnectorOnly) { + if (!defaultConnectorId || defaultConnectorId === 'NO_DEFAULT_CONNECTOR') { + return response.ok({ body: { connectors: [], soEntryFound: false } }); + } + const connector = await fetchConnectorById(defaultConnectorId); + return response.ok({ + body: { + connectors: connector ? [connector] : [], + soEntryFound: false, + }, + }); + } const [featureResult, allConnectors] = await Promise.all([ getForFeature(featureId, request).catch((e): ResolvedInferenceEndpoints => { @@ -66,11 +100,24 @@ export const defineInferenceConnectorsRoute = ({ }), ]); + const { soEntryFound } = featureResult; + const merged = mergeConnectors(featureResult.endpoints, allConnectors, soEntryFound); + + let connectors: ApiInferenceConnector[] = merged; + if (!soEntryFound && defaultConnectorId && defaultConnectorId !== 'NO_DEFAULT_CONNECTOR') { + const defaultConnector = await fetchConnectorById(defaultConnectorId); + if (defaultConnector) { + connectors = [ + defaultConnector, + ...merged.filter((c) => c.connectorId !== defaultConnectorId), + ]; + } + } + return response.ok({ body: { - connectors: featureResult.endpoints, - allConnectors, - soEntryFound: featureResult.soEntryFound, + connectors, + soEntryFound, }, }); }) diff --git a/x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_processing/create_steps.spec.ts b/x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_processing/create_steps.spec.ts index 062b4d23fd88b..cd6709ee9a5a4 100644 --- a/x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_processing/create_steps.spec.ts +++ b/x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_processing/create_steps.spec.ts @@ -44,7 +44,7 @@ test.describe( await page.route('**/internal/search_inference_endpoints/connectors*', async (route) => { await route.fulfill({ status: 200, - body: JSON.stringify({ connectors: [], allConnectors: [], soEntryFound: false }), + body: JSON.stringify({ connectors: [], soEntryFound: false }), }); }); await page.reload(); diff --git a/x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_processing/pipeline_suggestions.spec.ts b/x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_processing/pipeline_suggestions.spec.ts index 4163e441ee93b..1fcc2f79848b6 100644 --- a/x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_processing/pipeline_suggestions.spec.ts +++ b/x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_processing/pipeline_suggestions.spec.ts @@ -83,7 +83,7 @@ test.describe( await page.route('**/internal/search_inference_endpoints/connectors*', async (route) => { await route.fulfill({ status: 200, - body: JSON.stringify({ connectors: [], allConnectors: [], soEntryFound: false }), + body: JSON.stringify({ connectors: [], soEntryFound: false }), }); }); await page.reload(); diff --git a/x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_routing/ai_suggestions_button.spec.ts b/x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_routing/ai_suggestions_button.spec.ts index 1628ca6873259..d5f9152213944 100644 --- a/x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_routing/ai_suggestions_button.spec.ts +++ b/x-pack/platform/plugins/shared/streams_app/test/scout/ui/tests/data_management/data_routing/ai_suggestions_button.spec.ts @@ -53,7 +53,7 @@ test.describe.skip( await page.route('**/internal/search_inference_endpoints/connectors*', async (route) => { await route.fulfill({ status: 200, - body: JSON.stringify({ connectors: [], allConnectors: [], soEntryFound: false }), + body: JSON.stringify({ connectors: [], soEntryFound: false }), }); }); @@ -68,8 +68,7 @@ test.describe.skip( await route.fulfill({ status: 200, body: JSON.stringify({ - connectors: [], - allConnectors: [ + connectors: [ { connectorId: 'test-connector-1', name: 'Test Connector 1', @@ -89,6 +88,7 @@ test.describe.skip( isInferenceEndpoint: false, }, ], + allConnectors: [], soEntryFound: false, }), }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/management/cypress/tasks/insights.ts b/x-pack/solutions/security/plugins/security_solution/public/management/cypress/tasks/insights.ts index a802ddd83fce0..3ac00d7610c10 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/management/cypress/tasks/insights.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/management/cypress/tasks/insights.ts @@ -104,8 +104,7 @@ export const stubInferenceConnectorsApiResponse = (connectorId: string, connecto }, (req) => { req.reply(200, { - connectors: [], - allConnectors: [ + connectors: [ { connectorId, name: connectorName,