Skip to content

Commit e6caba2

Browse files
sphilipseclaude
andauthored
[Inference] Only save explicitly saved inference endpoint lists (#259849)
## Summary Only save changed inference endpoint assignments. - Filter out assignments matching recommended defaults before saving to the inference settings saved object, so the server-side fallback chain (recommendedEndpoints => parent.recommendedEndpoints) stays active for unchanged features. - Fall back to the Kibana default chat completion endpoint when a feature has no recommended endpoints and no parent with recommendations, instead of showing an empty list. ### Checklist Check the PR satisfies following conditions. Reviewers should verify this PR satisfies this list as well. - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md) - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a56a8dc commit e6caba2

4 files changed

Lines changed: 113 additions & 22 deletions

File tree

x-pack/platform/plugins/shared/search_inference_endpoints/public/components/settings/use_model_settings_form.test.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
*/
77

88
import { renderHook, act } from '@testing-library/react';
9+
import { defaultInferenceEndpoints } from '@kbn/inference-common';
910
import { useModelSettingsForm } from './use_model_settings_form';
1011
import { useRegisteredFeatures } from '../../hooks/use_registered_features';
1112
import { useInferenceSettings, useSaveInferenceSettings } from '../../hooks/use_inference_settings';
@@ -142,10 +143,7 @@ describe('useModelSettingsForm', () => {
142143
});
143144

144145
expect(mockSaveSettings).toHaveBeenCalledWith({
145-
features: expect.arrayContaining([
146-
{ feature_id: 'child_1', endpoints: [{ id: 'ep-1' }, { id: 'ep-2' }] },
147-
{ feature_id: 'child_2', endpoints: [{ id: 'endpoint-c' }] },
148-
]),
146+
features: [{ feature_id: 'child_1', endpoints: [{ id: 'ep-1' }, { id: 'ep-2' }] }],
149147
});
150148
});
151149

@@ -163,6 +161,7 @@ describe('useModelSettingsForm', () => {
163161
expect(result.current.assignments.child_1).toEqual(['endpoint-a', 'endpoint-b']);
164162
expect(result.current.assignments.child_2).toEqual(['endpoint-c']);
165163
expect(mockSaveSettings).toHaveBeenCalledTimes(1);
164+
expect(mockSaveSettings).toHaveBeenCalledWith({ features: [] });
166165
});
167166

168167
it('resetSection does nothing for unknown section', () => {
@@ -175,7 +174,7 @@ describe('useModelSettingsForm', () => {
175174
expect(mockSaveSettings).not.toHaveBeenCalled();
176175
});
177176

178-
it('falls back to parent recommendedEndpoints when child has none', () => {
177+
it('falls back to Kibana default endpoint when child and parent have no recommendations', () => {
179178
const childWithNoRecommended: InferenceFeatureConfig = {
180179
...childFeature2,
181180
recommendedEndpoints: [],
@@ -187,7 +186,9 @@ describe('useModelSettingsForm', () => {
187186

188187
const { result } = renderHook(() => useModelSettingsForm());
189188

190-
expect(result.current.assignments.child_2).toEqual([]);
189+
expect(result.current.assignments.child_2).toEqual([
190+
defaultInferenceEndpoints.KIBANA_DEFAULT_CHAT_COMPLETION,
191+
]);
191192
});
192193

193194
it('falls back to parent recommendedEndpoints when parent has them and child has none', () => {
@@ -235,6 +236,30 @@ describe('useModelSettingsForm', () => {
235236
expect(result.current.assignments.child_2).toEqual(['parent-ep']);
236237
});
237238

239+
it('save excludes features matching Kibana default endpoint from payload', () => {
240+
const childWithNoRecommended: InferenceFeatureConfig = {
241+
...childFeature2,
242+
recommendedEndpoints: [],
243+
};
244+
mockUseRegisteredFeatures.mockReturnValue({
245+
features: [parentFeature, childFeature1, childWithNoRecommended],
246+
isLoading: false,
247+
});
248+
249+
const { result } = renderHook(() => useModelSettingsForm());
250+
251+
act(() => {
252+
result.current.updateEndpoints('child_1', ['ep-1']);
253+
});
254+
act(() => {
255+
result.current.save();
256+
});
257+
258+
expect(mockSaveSettings).toHaveBeenCalledWith({
259+
features: [{ feature_id: 'child_1', endpoints: [{ id: 'ep-1' }] }],
260+
});
261+
});
262+
238263
it('isLoading is true when any dependency is loading', () => {
239264
mockUseRegisteredFeatures.mockReturnValue({ features: [], isLoading: true });
240265
const { result } = renderHook(() => useModelSettingsForm());

x-pack/platform/plugins/shared/search_inference_endpoints/public/components/settings/use_model_settings_form.ts

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
*/
77

88
import { useCallback, useEffect, useMemo, useState } from 'react';
9+
import { defaultInferenceEndpoints } from '@kbn/inference-common';
910
import { useInferenceSettings, useSaveInferenceSettings } from '../../hooks/use_inference_settings';
1011
import { useRegisteredFeatures } from '../../hooks/use_registered_features';
1112
import type { InferenceFeatureResponse } from '../../../common/types';
@@ -30,19 +31,49 @@ export interface ModelSettingsForm {
3031
const getEffectiveEndpoints = (
3132
feature: { recommendedEndpoints: string[]; parentFeatureId?: string },
3233
recommendedEndpointsById: Map<string, string[]>
33-
): string[] =>
34-
feature.recommendedEndpoints.length > 0
35-
? feature.recommendedEndpoints
36-
: feature.parentFeatureId
37-
? recommendedEndpointsById.get(feature.parentFeatureId) ?? []
38-
: [];
34+
): string[] => {
35+
if (feature.recommendedEndpoints.length > 0) {
36+
return feature.recommendedEndpoints;
37+
}
38+
if (feature.parentFeatureId) {
39+
const parentEndpoints = recommendedEndpointsById.get(feature.parentFeatureId) ?? [];
40+
if (parentEndpoints.length > 0) {
41+
return parentEndpoints;
42+
}
43+
}
44+
return [defaultInferenceEndpoints.KIBANA_DEFAULT_CHAT_COMPLETION];
45+
};
3946

4047
const toApiFormat = (assignments: Assignments) =>
4148
Object.entries(assignments).map(([featureId, ids]) => ({
4249
feature_id: featureId,
4350
endpoints: ids.map((id) => ({ id })),
4451
}));
4552

53+
const arraysEqual = (a: string[], b: string[]) =>
54+
a.length === b.length && a.every((v, i) => v === b[i]);
55+
56+
/**
57+
* Returns only the assignments that differ from the recommended defaults.
58+
* Features whose endpoints match the defaults are omitted so the server-side
59+
* fallback chain (recommendedEndpoints → parent) stays in effect.
60+
*/
61+
const getChangedAssignments = (
62+
assignments: Assignments,
63+
registeredFeatures: InferenceFeatureResponse[],
64+
recommendedEndpointsById: Map<string, string[]>
65+
): Assignments => {
66+
const featureById = new Map(registeredFeatures.map((f) => [f.featureId, f]));
67+
return Object.fromEntries(
68+
Object.entries(assignments).filter(([featureId, ids]) => {
69+
const feature = featureById.get(featureId);
70+
if (!feature) return true;
71+
const defaults = getEffectiveEndpoints(feature, recommendedEndpointsById);
72+
return !arraysEqual(ids, defaults);
73+
})
74+
);
75+
};
76+
4677
export const useModelSettingsForm = (): ModelSettingsForm => {
4778
const { features: registeredFeatures, isLoading: isFeaturesLoading } = useRegisteredFeatures();
4879
const { data: settingsData, isLoading: isSettingsLoading } = useInferenceSettings();
@@ -114,8 +145,13 @@ export const useModelSettingsForm = (): ModelSettingsForm => {
114145
}, []);
115146

116147
const save = useCallback(() => {
117-
saveSettings({ features: toApiFormat(assignments) });
118-
}, [saveSettings, assignments]);
148+
const changed = getChangedAssignments(
149+
assignments,
150+
registeredFeatures,
151+
recommendedEndpointsById
152+
);
153+
saveSettings({ features: toApiFormat(changed) });
154+
}, [saveSettings, assignments, registeredFeatures, recommendedEndpointsById]);
119155

120156
const resetSection = useCallback(
121157
(sectionId: string) => {
@@ -130,9 +166,10 @@ export const useModelSettingsForm = (): ModelSettingsForm => {
130166
);
131167
const updated = { ...assignments, ...resetEntries };
132168
setAssignments(updated);
133-
saveSettings({ features: toApiFormat(updated) });
169+
const changed = getChangedAssignments(updated, registeredFeatures, recommendedEndpointsById);
170+
saveSettings({ features: toApiFormat(changed) });
134171
},
135-
[assignments, sections, saveSettings, recommendedEndpointsById]
172+
[assignments, sections, saveSettings, recommendedEndpointsById, registeredFeatures]
136173
);
137174

138175
return {

x-pack/platform/plugins/shared/search_inference_endpoints/server/inference_endpoints.test.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@
77

88
import type { ISavedObjectsRepository, Logger, SavedObject } from '@kbn/core/server';
99
import { SavedObjectsErrorHelpers } from '@kbn/core/server';
10-
import { type InferenceConnector, InferenceConnectorType } from '@kbn/inference-common';
10+
import {
11+
type InferenceConnector,
12+
InferenceConnectorType,
13+
defaultInferenceEndpoints,
14+
} from '@kbn/inference-common';
1115
import { loggingSystemMock } from '@kbn/core/server/mocks';
1216
import type { InferenceFeatureConfig } from './types';
1317
import type { InferenceSettingsAttributes } from '../common/types';
@@ -97,17 +101,18 @@ describe('getForFeature', () => {
97101
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('not registered'));
98102
});
99103

100-
it('returns empty endpoints for features with no SO and no recommendations', async () => {
104+
it('falls back to Kibana default endpoint when no SO and no recommendations', async () => {
105+
const defaultEp = defaultInferenceEndpoints.KIBANA_DEFAULT_CHAT_COMPLETION;
101106
registry.register(createValidFeature({ featureId: 'f1', taskType: 'chat_completion' }));
102107
const result = await getForFeature(
103108
registry,
104109
createSoClient(),
105-
createGetConnectorById([]),
110+
createGetConnectorById([defaultEp]),
106111
'f1',
107112
logger
108113
);
109114
expect(result).toEqual({
110-
endpoints: [],
115+
endpoints: [createConnector(defaultEp)],
111116
warnings: [],
112117
soEntryFound: false,
113118
});
@@ -394,6 +399,28 @@ describe('getForFeature', () => {
394399
});
395400
});
396401

402+
it('prefers recommendedEndpoints over Kibana default endpoint', async () => {
403+
registry.register(
404+
createValidFeature({
405+
featureId: 'f1',
406+
recommendedEndpoints: ['rec1'],
407+
})
408+
);
409+
await expect(
410+
getForFeature(
411+
registry,
412+
createSoClient(),
413+
createGetConnectorById(['rec1', defaultInferenceEndpoints.KIBANA_DEFAULT_CHAT_COMPLETION]),
414+
'f1',
415+
logger
416+
)
417+
).resolves.toEqual({
418+
endpoints: [createConnector('rec1')],
419+
warnings: [],
420+
soEntryFound: false,
421+
});
422+
});
423+
397424
it('prefers grandparent SO override over all recommendedEndpoints', async () => {
398425
registry.register(
399426
createValidFeature({ featureId: 'grandparent', recommendedEndpoints: ['gp_rec'] })

x-pack/platform/plugins/shared/search_inference_endpoints/server/inference_endpoints.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import type { ISavedObjectsRepository, Logger } from '@kbn/core/server';
99
import { SavedObjectsErrorHelpers } from '@kbn/core/server';
1010
import { i18n } from '@kbn/i18n';
11-
import type { InferenceConnector } from '@kbn/inference-common';
11+
import { type InferenceConnector, defaultInferenceEndpoints } from '@kbn/inference-common';
1212
import { INFERENCE_SETTINGS_SO_TYPE, INFERENCE_SETTINGS_ID } from '../common/constants';
1313
import type { InferenceSettingsAttributes } from '../common/types';
1414
import type { InferenceFeatureRegistry } from './inference_feature_registry';
@@ -177,7 +177,9 @@ const resolveEndpointIds = async (
177177
}
178178

179179
return {
180-
ids: recEntry?.recommendedEndpoints ?? [],
180+
ids: recEntry?.recommendedEndpoints ?? [
181+
defaultInferenceEndpoints.KIBANA_DEFAULT_CHAT_COMPLETION,
182+
],
181183
warnings: [],
182184
soEntryFound: false,
183185
};

0 commit comments

Comments
 (0)