From e5e144a21db58ddb9f813eba4adca1f3c35454a2 Mon Sep 17 00:00:00 2001 From: Sander Philipse <94373878+sphilipse@users.noreply.github.com> Date: Tue, 21 Apr 2026 15:24:46 +0200 Subject: [PATCH] [Inference] Align getForFeature with useLoadConnectors logic (#264166) ## Summary This applies the correct behave to the getForFeature function, which was not yet doing this. - When `defaultOnly` is set, only the default connector is returned (or an empty list if the default is missing, unset, or set to the `NO_DEFAULT_CONNECTOR` sentinel). - When the feature has no admin SO override, the default connector is prepended to the resolved endpoints and deduplicated. - When an SO override exists for the feature, the default is ignored so admin intent wins. --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Elastic Machine (cherry picked from commit 90e9aae3e347b7286dd61c05fe2fc14a3fdfbe28) --- .../server/inference_endpoints.test.ts | 210 +++++++++++++++++- .../server/inference_endpoints.ts | 85 ++++++- .../server/plugin.ts | 17 +- 3 files changed, 304 insertions(+), 8 deletions(-) diff --git a/x-pack/platform/plugins/shared/search_inference_endpoints/server/inference_endpoints.test.ts b/x-pack/platform/plugins/shared/search_inference_endpoints/server/inference_endpoints.test.ts index e7f0b18115daf..57b72d0c3d171 100644 --- a/x-pack/platform/plugins/shared/search_inference_endpoints/server/inference_endpoints.test.ts +++ b/x-pack/platform/plugins/shared/search_inference_endpoints/server/inference_endpoints.test.ts @@ -5,7 +5,12 @@ * 2.0. */ -import type { ISavedObjectsRepository, Logger, SavedObject } from '@kbn/core/server'; +import type { + ISavedObjectsRepository, + IUiSettingsClient, + Logger, + SavedObject, +} from '@kbn/core/server'; import { SavedObjectsErrorHelpers } from '@kbn/core/server'; import { type InferenceConnector, @@ -13,10 +18,14 @@ import { defaultInferenceEndpoints, } from '@kbn/inference-common'; import { loggingSystemMock } from '@kbn/core/server/mocks'; +import { + GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR, + GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR_DEFAULT_ONLY, +} from '@kbn/management-settings-ids'; import type { InferenceFeatureConfig } from './types'; import type { InferenceSettingsAttributes } from '../common/types'; import { InferenceFeatureRegistry } from './inference_feature_registry'; -import { getForFeature } from './inference_endpoints'; +import { getForFeature, getForFeatureWithDefault } from './inference_endpoints'; const createValidFeature = ( overrides: Partial = {} @@ -454,3 +463,200 @@ describe('getForFeature', () => { }); }); }); + +interface UiSettings { + defaultConnectorId?: string; + defaultConnectorOnly?: boolean; +} + +const createUiSettingsClient = ({ + defaultConnectorId, + defaultConnectorOnly, +}: UiSettings = {}): IUiSettingsClient => + ({ + 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 IUiSettingsClient); + +describe('getForFeatureWithDefault', () => { + let registry: InferenceFeatureRegistry; + let logger: Logger; + + beforeEach(() => { + logger = loggingSystemMock.createLogger(); + registry = new InferenceFeatureRegistry(logger); + }); + + it('returns only the default connector when defaultConnectorOnly is set', async () => { + registry.register(createValidFeature({ featureId: 'f1', recommendedEndpoints: ['rec1'] })); + const result = await getForFeatureWithDefault({ + registry, + soClient: createSoClient(), + uiSettingsClient: createUiSettingsClient({ + defaultConnectorId: 'default-id', + defaultConnectorOnly: true, + }), + getConnectorById: createGetConnectorById(['default-id', 'rec1']), + featureId: 'f1', + logger, + }); + expect(result).toEqual({ + endpoints: [createConnector('default-id')], + warnings: [], + soEntryFound: false, + }); + }); + + it('returns an empty list when defaultConnectorOnly is set but no default is configured', async () => { + registry.register(createValidFeature({ featureId: 'f1', recommendedEndpoints: ['rec1'] })); + const result = await getForFeatureWithDefault({ + registry, + soClient: createSoClient(), + uiSettingsClient: createUiSettingsClient({ defaultConnectorOnly: true }), + getConnectorById: createGetConnectorById(['rec1']), + featureId: 'f1', + logger, + }); + expect(result).toEqual({ endpoints: [], warnings: [], soEntryFound: false }); + }); + + it('returns an empty list when defaultConnectorOnly is set with the NO_DEFAULT_CONNECTOR sentinel', async () => { + registry.register(createValidFeature({ featureId: 'f1', recommendedEndpoints: ['rec1'] })); + const result = await getForFeatureWithDefault({ + registry, + soClient: createSoClient(), + uiSettingsClient: createUiSettingsClient({ + defaultConnectorId: 'NO_DEFAULT_CONNECTOR', + defaultConnectorOnly: true, + }), + getConnectorById: createGetConnectorById(['rec1']), + featureId: 'f1', + logger, + }); + expect(result).toEqual({ endpoints: [], warnings: [], soEntryFound: false }); + }); + + it('returns an empty list when defaultConnectorOnly is set but the default connector lookup fails', async () => { + registry.register(createValidFeature({ featureId: 'f1', recommendedEndpoints: ['rec1'] })); + const result = await getForFeatureWithDefault({ + registry, + soClient: createSoClient(), + uiSettingsClient: createUiSettingsClient({ + defaultConnectorId: 'missing', + defaultConnectorOnly: true, + }), + getConnectorById: createGetConnectorById(['rec1']), + featureId: 'f1', + logger, + }); + expect(result).toEqual({ endpoints: [], warnings: [], soEntryFound: false }); + }); + + it('prepends the default connector when no SO entry is found', async () => { + registry.register(createValidFeature({ featureId: 'f1', recommendedEndpoints: ['rec1'] })); + const result = await getForFeatureWithDefault({ + registry, + soClient: createSoClient(), + uiSettingsClient: createUiSettingsClient({ defaultConnectorId: 'default-id' }), + getConnectorById: createGetConnectorById(['default-id', 'rec1']), + featureId: 'f1', + logger, + }); + expect(result).toEqual({ + endpoints: [createConnector('default-id'), createConnector('rec1')], + warnings: [], + soEntryFound: false, + }); + }); + + it('deduplicates the default connector when already present in the resolved endpoints', async () => { + registry.register( + createValidFeature({ featureId: 'f1', recommendedEndpoints: ['default-id'] }) + ); + const result = await getForFeatureWithDefault({ + registry, + soClient: createSoClient(), + uiSettingsClient: createUiSettingsClient({ defaultConnectorId: 'default-id' }), + getConnectorById: createGetConnectorById(['default-id']), + featureId: 'f1', + logger, + }); + expect(result).toEqual({ + endpoints: [createConnector('default-id')], + warnings: [], + soEntryFound: false, + }); + }); + + it('ignores the default connector when an SO entry was found', async () => { + registry.register(createValidFeature({ featureId: 'f1' })); + const result = await getForFeatureWithDefault({ + registry, + soClient: createSoClient([{ feature_id: 'f1', endpoints: [{ id: 'so_ep' }] }]), + uiSettingsClient: createUiSettingsClient({ defaultConnectorId: 'default-id' }), + getConnectorById: createGetConnectorById(['default-id', 'so_ep']), + featureId: 'f1', + logger, + }); + expect(result).toEqual({ + endpoints: [createConnector('so_ep')], + warnings: [], + soEntryFound: true, + }); + }); + + it('ignores the NO_DEFAULT_CONNECTOR sentinel when resolving feature endpoints', async () => { + registry.register(createValidFeature({ featureId: 'f1', recommendedEndpoints: ['rec1'] })); + const result = await getForFeatureWithDefault({ + registry, + soClient: createSoClient(), + uiSettingsClient: createUiSettingsClient({ defaultConnectorId: 'NO_DEFAULT_CONNECTOR' }), + getConnectorById: createGetConnectorById(['rec1']), + featureId: 'f1', + logger, + }); + expect(result).toEqual({ + endpoints: [createConnector('rec1')], + warnings: [], + soEntryFound: false, + }); + }); + + it('returns the feature endpoints unchanged when the default connector lookup fails', async () => { + registry.register(createValidFeature({ featureId: 'f1', recommendedEndpoints: ['rec1'] })); + const result = await getForFeatureWithDefault({ + registry, + soClient: createSoClient(), + uiSettingsClient: createUiSettingsClient({ defaultConnectorId: 'missing' }), + getConnectorById: createGetConnectorById(['rec1']), + featureId: 'f1', + logger, + }); + expect(result).toEqual({ + endpoints: [createConnector('rec1')], + warnings: [], + soEntryFound: false, + }); + }); + + it('returns the feature endpoints when no default is configured', async () => { + registry.register(createValidFeature({ featureId: 'f1', recommendedEndpoints: ['rec1'] })); + const result = await getForFeatureWithDefault({ + registry, + soClient: createSoClient(), + uiSettingsClient: createUiSettingsClient(), + getConnectorById: createGetConnectorById(['rec1']), + featureId: 'f1', + logger, + }); + expect(result).toEqual({ + endpoints: [createConnector('rec1')], + warnings: [], + soEntryFound: false, + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/search_inference_endpoints/server/inference_endpoints.ts b/x-pack/platform/plugins/shared/search_inference_endpoints/server/inference_endpoints.ts index 21d39f1e35c6d..784c791651256 100644 --- a/x-pack/platform/plugins/shared/search_inference_endpoints/server/inference_endpoints.ts +++ b/x-pack/platform/plugins/shared/search_inference_endpoints/server/inference_endpoints.ts @@ -5,15 +5,21 @@ * 2.0. */ -import type { ISavedObjectsRepository, Logger } from '@kbn/core/server'; +import type { ISavedObjectsRepository, IUiSettingsClient, Logger } from '@kbn/core/server'; import { SavedObjectsErrorHelpers } from '@kbn/core/server'; import { i18n } from '@kbn/i18n'; import { type InferenceConnector, defaultInferenceEndpoints } 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 { INFERENCE_SETTINGS_SO_TYPE, INFERENCE_SETTINGS_ID } from '../common/constants'; import type { InferenceSettingsAttributes } from '../common/types'; import type { InferenceFeatureRegistry } from './inference_feature_registry'; import type { ResolvedInferenceEndpoints } from './types'; +const NO_DEFAULT_CONNECTOR = 'NO_DEFAULT_CONNECTOR'; + /** * Returns the resolved inference endpoints for a feature. * Walks the fallback chain (admin SO override → recommendedEndpoints → parent feature) @@ -48,6 +54,83 @@ export const getForFeature = async ( }; }; +/** + * Resolves endpoints for a feature and layers on the global default AI connector + * configured via advanced settings (`GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR` and + * `GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR_DEFAULT_ONLY`). + * + * - When `defaultOnly` is set, only the default connector is returned (or an empty list). + * - When the feature has no admin-configured SO override, the default connector is + * prepended to the resolved endpoints. + * - When an SO override exists for the feature, the default is ignored. + * + * Kept in sync with the HTTP route at `GET /internal/search_inference_endpoints/connectors`. + */ +export const getForFeatureWithDefault = async ({ + registry, + soClient, + uiSettingsClient, + getConnectorById, + featureId, + logger, +}: { + registry: InferenceFeatureRegistry; + soClient: ISavedObjectsRepository; + uiSettingsClient: IUiSettingsClient; + getConnectorById: (id: string) => Promise; + featureId: string; + logger: Logger; +}): Promise => { + 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 hasDefault = + typeof defaultConnectorId === 'string' && + defaultConnectorId.length > 0 && + defaultConnectorId !== NO_DEFAULT_CONNECTOR; + + const fetchDefault = async (): Promise => { + if (!hasDefault) return undefined; + try { + return await getConnectorById(defaultConnectorId); + } catch (e) { + logger.warn(`Failed to load default connector "${defaultConnectorId}": ${e.message}`); + return undefined; + } + }; + + if (defaultConnectorOnly) { + const defaultConnector = await fetchDefault(); + return { + endpoints: defaultConnector ? [defaultConnector] : [], + warnings: [], + soEntryFound: false, + }; + } + + const result = await getForFeature(registry, soClient, getConnectorById, featureId, logger); + + if (result.soEntryFound || !hasDefault) { + return result; + } + + const defaultConnector = await fetchDefault(); + if (!defaultConnector) { + return result; + } + + return { + endpoints: [ + defaultConnector, + ...result.endpoints.filter((c) => c.connectorId !== defaultConnector.connectorId), + ], + warnings: result.warnings, + soEntryFound: false, + }; +}; + /** * Fetches connectors by their IDs using getConnectorById. * Returns warnings for any IDs that were not found. diff --git a/x-pack/platform/plugins/shared/search_inference_endpoints/server/plugin.ts b/x-pack/platform/plugins/shared/search_inference_endpoints/server/plugin.ts index 02666a59e0593..9b9577e0d2c81 100644 --- a/x-pack/platform/plugins/shared/search_inference_endpoints/server/plugin.ts +++ b/x-pack/platform/plugins/shared/search_inference_endpoints/server/plugin.ts @@ -20,7 +20,10 @@ import type { SearchInferenceEndpointsConfig } from './config'; import { DynamicConnectorsPoller } from './lib/dynamic_connectors'; import { defineRoutes } from './routes'; import { InferenceFeatureRegistry } from './inference_feature_registry'; -import { getForFeature as getForFeatureFn } from './inference_endpoints'; +import { + getForFeature as getForFeatureFn, + getForFeatureWithDefault as getForFeatureWithDefaultFn, +} from './inference_endpoints'; import { createInferenceSettingsSavedObjectType } from './saved_objects/inference_settings'; import type { SearchInferenceEndpointsPluginSetup, @@ -179,14 +182,18 @@ export class SearchInferenceEndpointsPlugin endpoints: { getForFeature: (featureId: string, request: KibanaRequest) => { const soClient = core.savedObjects.createInternalRepository([INFERENCE_SETTINGS_SO_TYPE]); + const uiSettingsClient = core.uiSettings.asScopedToClient( + core.savedObjects.getScopedClient(request) + ); const getConnectorById = (id: string) => plugins.inference.getConnectorById(id, request); - return getForFeatureFn( - featureRegistry, + return getForFeatureWithDefaultFn({ + registry: featureRegistry, soClient, + uiSettingsClient, getConnectorById, featureId, - this.logger - ); + logger: this.logger, + }); }, }, };