@theia/ai-core 1.76.0-next.28 → 1.76.0-next.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/lib/browser/ai-core-frontend-module.d.ts.map +1 -1
  2. package/lib/browser/ai-core-frontend-module.js +4 -0
  3. package/lib/browser/ai-core-frontend-module.js.map +1 -1
  4. package/lib/browser/discovering-provider-contribution.d.ts +92 -0
  5. package/lib/browser/discovering-provider-contribution.d.ts.map +1 -0
  6. package/lib/browser/discovering-provider-contribution.js +189 -0
  7. package/lib/browser/discovering-provider-contribution.js.map +1 -0
  8. package/lib/browser/discovering-provider-contribution.spec.d.ts +2 -0
  9. package/lib/browser/discovering-provider-contribution.spec.d.ts.map +1 -0
  10. package/lib/browser/discovering-provider-contribution.spec.js +232 -0
  11. package/lib/browser/discovering-provider-contribution.spec.js.map +1 -0
  12. package/lib/browser/favorite-models-service.d.ts +135 -0
  13. package/lib/browser/favorite-models-service.d.ts.map +1 -0
  14. package/lib/browser/favorite-models-service.js +279 -0
  15. package/lib/browser/favorite-models-service.js.map +1 -0
  16. package/lib/browser/favorite-models-service.spec.d.ts +2 -0
  17. package/lib/browser/favorite-models-service.spec.d.ts.map +1 -0
  18. package/lib/browser/favorite-models-service.spec.js +277 -0
  19. package/lib/browser/favorite-models-service.spec.js.map +1 -0
  20. package/lib/browser/index.d.ts +3 -0
  21. package/lib/browser/index.d.ts.map +1 -1
  22. package/lib/browser/index.js +3 -0
  23. package/lib/browser/index.js.map +1 -1
  24. package/lib/browser/model-discovery-status-service.d.ts +51 -0
  25. package/lib/browser/model-discovery-status-service.d.ts.map +1 -0
  26. package/lib/browser/model-discovery-status-service.js +103 -0
  27. package/lib/browser/model-discovery-status-service.js.map +1 -0
  28. package/lib/browser/model-discovery-status-service.spec.d.ts +2 -0
  29. package/lib/browser/model-discovery-status-service.spec.d.ts.map +1 -0
  30. package/lib/browser/model-discovery-status-service.spec.js +81 -0
  31. package/lib/browser/model-discovery-status-service.spec.js.map +1 -0
  32. package/lib/common/ai-core-preferences.d.ts +11 -0
  33. package/lib/common/ai-core-preferences.d.ts.map +1 -1
  34. package/lib/common/ai-core-preferences.js +37 -1
  35. package/lib/common/ai-core-preferences.js.map +1 -1
  36. package/lib/common/index.d.ts +2 -0
  37. package/lib/common/index.d.ts.map +1 -1
  38. package/lib/common/index.js +2 -0
  39. package/lib/common/index.js.map +1 -1
  40. package/lib/common/language-model-ordering.spec.d.ts +2 -0
  41. package/lib/common/language-model-ordering.spec.d.ts.map +1 -0
  42. package/lib/common/language-model-ordering.spec.js +72 -0
  43. package/lib/common/language-model-ordering.spec.js.map +1 -0
  44. package/lib/common/language-model-util.d.ts +17 -1
  45. package/lib/common/language-model-util.d.ts.map +1 -1
  46. package/lib/common/language-model-util.js +44 -1
  47. package/lib/common/language-model-util.js.map +1 -1
  48. package/lib/common/language-model.d.ts +5 -0
  49. package/lib/common/language-model.d.ts.map +1 -1
  50. package/lib/common/language-model.js.map +1 -1
  51. package/lib/common/model-discovery-status.d.ts +112 -0
  52. package/lib/common/model-discovery-status.d.ts.map +1 -0
  53. package/lib/common/model-discovery-status.js +18 -0
  54. package/lib/common/model-discovery-status.js.map +1 -0
  55. package/lib/common/model-discovery-util.d.ts +46 -0
  56. package/lib/common/model-discovery-util.d.ts.map +1 -0
  57. package/lib/common/model-discovery-util.js +128 -0
  58. package/lib/common/model-discovery-util.js.map +1 -0
  59. package/lib/common/model-discovery-util.spec.d.ts +2 -0
  60. package/lib/common/model-discovery-util.spec.d.ts.map +1 -0
  61. package/lib/common/model-discovery-util.spec.js +95 -0
  62. package/lib/common/model-discovery-util.spec.js.map +1 -0
  63. package/lib/node/ai-core-backend-module.d.ts.map +1 -1
  64. package/lib/node/ai-core-backend-module.js +8 -0
  65. package/lib/node/ai-core-backend-module.js.map +1 -1
  66. package/lib/node/backend-language-model-registry.d.ts.map +1 -1
  67. package/lib/node/backend-language-model-registry.js +1 -0
  68. package/lib/node/backend-language-model-registry.js.map +1 -1
  69. package/lib/node/index.d.ts +2 -0
  70. package/lib/node/index.d.ts.map +1 -1
  71. package/lib/node/index.js +2 -0
  72. package/lib/node/index.js.map +1 -1
  73. package/lib/node/model-discovery-fetcher.d.ts +43 -0
  74. package/lib/node/model-discovery-fetcher.d.ts.map +1 -0
  75. package/lib/node/model-discovery-fetcher.js +83 -0
  76. package/lib/node/model-discovery-fetcher.js.map +1 -0
  77. package/lib/node/model-snapshot-store.d.ts +26 -0
  78. package/lib/node/model-snapshot-store.d.ts.map +1 -0
  79. package/lib/node/model-snapshot-store.js +73 -0
  80. package/lib/node/model-snapshot-store.js.map +1 -0
  81. package/lib/node/test/test-model-discovery-fetcher.d.ts +21 -0
  82. package/lib/node/test/test-model-discovery-fetcher.d.ts.map +1 -0
  83. package/lib/node/test/test-model-discovery-fetcher.js +54 -0
  84. package/lib/node/test/test-model-discovery-fetcher.js.map +1 -0
  85. package/package.json +9 -9
  86. package/src/browser/ai-core-frontend-module.ts +5 -0
  87. package/src/browser/discovering-provider-contribution.spec.ts +303 -0
  88. package/src/browser/discovering-provider-contribution.ts +241 -0
  89. package/src/browser/favorite-models-service.spec.ts +336 -0
  90. package/src/browser/favorite-models-service.ts +297 -0
  91. package/src/browser/index.ts +3 -0
  92. package/src/browser/model-discovery-status-service.spec.ts +95 -0
  93. package/src/browser/model-discovery-status-service.ts +118 -0
  94. package/src/common/ai-core-preferences.ts +40 -0
  95. package/src/common/index.ts +2 -0
  96. package/src/common/language-model-ordering.spec.ts +83 -0
  97. package/src/common/language-model-util.ts +43 -0
  98. package/src/common/language-model.ts +5 -0
  99. package/src/common/model-discovery-status.ts +132 -0
  100. package/src/common/model-discovery-util.spec.ts +105 -0
  101. package/src/common/model-discovery-util.ts +129 -0
  102. package/src/node/ai-core-backend-module.ts +8 -0
  103. package/src/node/backend-language-model-registry.ts +1 -0
  104. package/src/node/index.ts +2 -0
  105. package/src/node/model-discovery-fetcher.ts +103 -0
  106. package/src/node/model-snapshot-store.ts +80 -0
  107. package/src/node/test/test-model-discovery-fetcher.ts +62 -0
@@ -0,0 +1,297 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 EclipseSource GmbH.
3
+ //
4
+ // This program and the accompanying materials are made available under the
5
+ // terms of the Eclipse Public License v. 2.0 which is available at
6
+ // http://www.eclipse.org/legal/epl-2.0.
7
+ //
8
+ // This Source Code may also be made available under the following Secondary
9
+ // Licenses when the conditions for such availability set forth in the Eclipse
10
+ // Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
+ // with the GNU Classpath Exception which is available at
12
+ // https://www.gnu.org/software/classpath/license.html.
13
+ //
14
+ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
+ // *****************************************************************************
16
+
17
+ import { Emitter, Event, PreferenceScope, PreferenceService } from '@theia/core';
18
+ import { inject, injectable, postConstruct } from '@theia/core/shared/inversify';
19
+ import { PREFERENCE_NAME_FAVORITE_MODELS, PREFERENCE_NAME_HIDDEN_MODELS } from '../common/ai-core-preferences';
20
+ import { DiscoveredModel, ModelDiscoveryStatus } from '../common/model-discovery-status';
21
+ import { DiscoveredModels } from '../common/model-discovery-util';
22
+ import { ModelDiscoveryStatusService } from './model-discovery-status-service';
23
+
24
+ /**
25
+ * How many of a provider's models are offered without the user asking. Enough to cover a provider's
26
+ * current line-up (a flagship, a fast one, a cheap one and their immediate predecessors) without
27
+ * turning the model pickers back into a catalogue.
28
+ */
29
+ export const FEATURED_MODEL_COUNT = 5;
30
+
31
+ /** What {@link FavoriteModelsService} derives from what the providers last reported. */
32
+ export interface DerivedFavorites {
33
+ /** Fully qualified ids of the models that are offered without the user asking. */
34
+ readonly featured: Set<string>;
35
+ /** Model-id prefixes of the providers that participate in discovery. */
36
+ readonly discoveryPrefixes: Set<string>;
37
+ }
38
+
39
+ /**
40
+ * Decides which models the model picker in the AI chat input offers.
41
+ *
42
+ * Discovery registers everything a provider offers, including one entry per release, which is far
43
+ * more than anyone picks between while chatting. What that picker shows is therefore the *favorites*:
44
+ * the models that are featured automatically plus the ones the user marked. Everywhere a model is
45
+ * chosen deliberately — an agent's model, a model alias — every discovered model stays on offer.
46
+ *
47
+ * A model is featured when it is one of the {@link FEATURED_MODEL_COUNT} most recently released
48
+ * undated ids of its provider — so `claude-opus-5` is featured while `claude-opus-5-20260401` is
49
+ * not. Because the featured set is derived from each discovery rather than stored, a model released
50
+ * tomorrow is offered the day it appears, and the user cannot end up pinned to a stale line-up.
51
+ *
52
+ * Both directions are the user's to override, and only the override is stored: a model offered by
53
+ * default is taken out of the picker by an entry in {@link PREFERENCE_NAME_HIDDEN_MODELS}, any other
54
+ * model is put into it by an entry in {@link PREFERENCE_NAME_FAVORITE_MODELS}. A model nobody touched
55
+ * appears in neither.
56
+ *
57
+ * Models of a provider that does not participate in discovery — one whose models are configured by
58
+ * hand, or a custom endpoint — are all favorites: listing them *was* the user's choice.
59
+ */
60
+ @injectable()
61
+ export class FavoriteModelsService {
62
+
63
+ @inject(PreferenceService)
64
+ protected readonly preferenceService: PreferenceService;
65
+
66
+ @inject(ModelDiscoveryStatusService)
67
+ protected readonly discoveryStatus: ModelDiscoveryStatusService;
68
+
69
+ protected readonly onDidChangeEmitter = new Emitter<void>();
70
+ /** Fired when the marked models or the featured set change. */
71
+ readonly onDidChange: Event<void> = this.onDidChangeEmitter.event;
72
+
73
+ /** Memoized {@link getDerived}, dropped whenever a discovery reports something new. */
74
+ protected derived: DerivedFavorites | undefined;
75
+
76
+ @postConstruct()
77
+ protected init(): void {
78
+ this.discoveryStatus.onDidChange(status => {
79
+ this.derived = undefined;
80
+ this.pruneFavorites(status);
81
+ this.onDidChangeEmitter.fire();
82
+ });
83
+ this.preferenceService.onPreferenceChanged(event => {
84
+ if (event.preferenceName === PREFERENCE_NAME_FAVORITE_MODELS || event.preferenceName === PREFERENCE_NAME_HIDDEN_MODELS) {
85
+ this.onDidChangeEmitter.fire();
86
+ }
87
+ });
88
+ }
89
+
90
+ /** Whether the chat input's model picker offers this model, be it featured, marked or hand-configured. */
91
+ isFavorite(modelId: string): boolean {
92
+ if (this.getHidden().includes(modelId)) {
93
+ return false;
94
+ }
95
+ return this.isOfferedByDefault(modelId) || this.getFavorites().includes(modelId);
96
+ }
97
+
98
+ /**
99
+ * Whether the picker would offer this model without the user marking it: one of the newest of a
100
+ * provider that discovers, or any model of a provider that does not. Nothing was discovered for
101
+ * the latter, so its models are there because someone put them there - a manually configured
102
+ * provider, or a custom endpoint. That is choice enough.
103
+ */
104
+ protected isOfferedByDefault(modelId: string): boolean {
105
+ return !this.participatesInDiscovery(modelId) || this.isFeatured(modelId);
106
+ }
107
+
108
+ /** Whether the model's provider reports a discovery status, i.e. whether featuring applies to it at all. */
109
+ protected participatesInDiscovery(modelId: string): boolean {
110
+ const separator = modelId.indexOf('/');
111
+ if (separator <= 0) {
112
+ return false;
113
+ }
114
+ return this.getDerived().discoveryPrefixes.has(modelId.substring(0, separator));
115
+ }
116
+
117
+ /**
118
+ * Whether the model is offered automatically because it is among its provider's newest, in which
119
+ * case it cannot be unmarked.
120
+ */
121
+ isFeatured(modelId: string): boolean {
122
+ return this.getFeatured().has(modelId);
123
+ }
124
+
125
+ /** The ids the user marked explicitly, the ones offered by default excluded. */
126
+ getFavorites(): string[] {
127
+ return this.preferenceService.get<string[]>(PREFERENCE_NAME_FAVORITE_MODELS, []);
128
+ }
129
+
130
+ /** The ids the user took out of the picker, which would otherwise be offered by default. */
131
+ getHidden(): string[] {
132
+ return this.preferenceService.get<string[]>(PREFERENCE_NAME_HIDDEN_MODELS, []);
133
+ }
134
+
135
+ /**
136
+ * Whether the user decided anything about this provider's models, i.e. whether resetting it to the
137
+ * models it offers by default would change what the picker shows.
138
+ *
139
+ * @param modelIdPrefix prefix of the provider's registered model ids, e.g. `openai`
140
+ */
141
+ hasOverrides(modelIdPrefix: string): boolean {
142
+ return [...this.getFavorites(), ...this.getHidden()].some(id => id.startsWith(`${modelIdPrefix}/`));
143
+ }
144
+
145
+ /**
146
+ * Drops what the user decided about one provider's models, so the picker shows the models that
147
+ * provider offers by default again. The other providers are left alone.
148
+ *
149
+ * @param modelIdPrefix prefix of the provider's registered model ids, e.g. `openai`
150
+ */
151
+ async resetToDefaults(modelIdPrefix: string): Promise<void> {
152
+ for (const preferenceName of [PREFERENCE_NAME_FAVORITE_MODELS, PREFERENCE_NAME_HIDDEN_MODELS]) {
153
+ const ids = this.preferenceService.get<string[]>(preferenceName, []);
154
+ const kept = ids.filter(id => !id.startsWith(`${modelIdPrefix}/`));
155
+ if (kept.length !== ids.length) {
156
+ await this.write(preferenceName, kept);
157
+ }
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Puts a model into the picker or takes it out of it, whichever the current state calls for. Only
163
+ * the deviation from the default is stored: a model offered by default is taken out by an entry in
164
+ * the hidden list, and any other by dropping its entry from the favorites (or adding one).
165
+ */
166
+ async toggleFavorite(modelId: string): Promise<void> {
167
+ const favorites = this.getFavorites();
168
+ const hidden = this.getHidden();
169
+ if (this.isFavorite(modelId)) {
170
+ if (favorites.includes(modelId)) {
171
+ await this.write(PREFERENCE_NAME_FAVORITE_MODELS, favorites.filter(id => id !== modelId));
172
+ }
173
+ if (this.isOfferedByDefault(modelId)) {
174
+ await this.write(PREFERENCE_NAME_HIDDEN_MODELS, [...hidden, modelId]);
175
+ }
176
+ return;
177
+ }
178
+ if (hidden.includes(modelId)) {
179
+ await this.write(PREFERENCE_NAME_HIDDEN_MODELS, hidden.filter(id => id !== modelId));
180
+ }
181
+ // A model that was hidden while it was featured and has since fallen out of the featured set
182
+ // needs a mark of its own, or removing the entry would not bring it back.
183
+ if (!this.isOfferedByDefault(modelId)) {
184
+ await this.write(PREFERENCE_NAME_FAVORITE_MODELS, [...favorites, modelId]);
185
+ }
186
+ }
187
+
188
+ protected async write(preferenceName: string, ids: string[]): Promise<void> {
189
+ await this.preferenceService.set(preferenceName, ids, PreferenceScope.User);
190
+ }
191
+
192
+ /** The featured ids across all providers that participate in discovery, fully qualified. */
193
+ protected getFeatured(): Set<string> {
194
+ return this.getDerived().featured;
195
+ }
196
+
197
+ /**
198
+ * What the last discovery implies, computed once and kept until it changes. The chat input asks
199
+ * per model on every render, and ranking each provider's models per question would mean sorting
200
+ * the whole catalogue as many times as it has entries.
201
+ */
202
+ protected getDerived(): DerivedFavorites {
203
+ if (!this.derived) {
204
+ const featured = new Set<string>();
205
+ const discoveryPrefixes = new Set<string>();
206
+ for (const status of this.discoveryStatus.getStatuses()) {
207
+ const prefix = status.modelIdPrefix ?? status.providerId;
208
+ discoveryPrefixes.add(prefix);
209
+ for (const model of this.selectFeatured(status)) {
210
+ featured.add(`${prefix}/${model.id}`);
211
+ }
212
+ }
213
+ this.derived = { featured, discoveryPrefixes };
214
+ }
215
+ return this.derived;
216
+ }
217
+
218
+ /**
219
+ * The models of one provider that are offered automatically: at most {@link FEATURED_MODEL_COUNT}
220
+ * of its undated ids. A release-pinned id is never featured — the undated form of the same model
221
+ * already is, and pinning a release is the deliberate choice the switch on its row makes.
222
+ *
223
+ * They are ranked by what the provider reports about them, in that order:
224
+ *
225
+ * 1. most recently released, where the provider reports release dates (Anthropic, OpenAI);
226
+ * 2. a `-latest` pointer the provider maintains at the current model of a family, which only
227
+ * decides where there are no dates: a provider that reports them dates its pointers as well;
228
+ * 3. highest version in the id, which is what is left for Gemini — the naming of those models
229
+ * carries their generation (`gemini-3.7-flash` over `gemini-2.5-pro`).
230
+ *
231
+ * What the model aliases name has no say in this: an alias is a curated list of its own, pointing
232
+ * at the model a purpose should use, which is a different question from which models are worth
233
+ * offering in the picker.
234
+ *
235
+ * An explicitly configured list (`modelOverrides`) is featured in full: the user named it. So is a
236
+ * provider's own nomination ({@link DiscoveredModel.featured}), which a provider makes when the
237
+ * generic ranking cannot serve it — Copilot carries models of many vendors and nominates one each
238
+ * of the three the other providers offer directly, where "the newest five" would pick five of
239
+ * whichever vendor numbers its models highest.
240
+ */
241
+ protected selectFeatured(status: ModelDiscoveryStatus): DiscoveredModel[] {
242
+ const discovered = status.discovered ?? [];
243
+ if (status.state === 'overridden') {
244
+ return [...discovered];
245
+ }
246
+ const nominated = discovered.filter(model => model.featured);
247
+ if (nominated.length > 0) {
248
+ return nominated;
249
+ }
250
+ return discovered
251
+ .filter(model => DiscoveredModels.undatedId(model.id) === undefined)
252
+ .map((model, index) => ({ model, index }))
253
+ .sort((left, right) => this.compareFeatured(left, right))
254
+ .slice(0, FEATURED_MODEL_COUNT)
255
+ .map(entry => entry.model);
256
+ }
257
+
258
+ /** Orders two candidates of the same provider; `index` preserves the reported order as the last resort. */
259
+ protected compareFeatured(
260
+ left: { model: DiscoveredModel; index: number },
261
+ right: { model: DiscoveredModel; index: number }
262
+ ): number {
263
+ const byRelease = (right.model.released ?? 0) - (left.model.released ?? 0);
264
+ if (byRelease !== 0) {
265
+ return byRelease;
266
+ }
267
+ // Only where a provider reports no dates: a pointer at its current model of a family is as
268
+ // current as a model gets, and it carries no version, so it would otherwise sort behind every
269
+ // id that does. Where there are dates, the pointer carries one too and has been ordered by it.
270
+ const byPointer = Number(DiscoveredModels.isLatestPointer(right.model.id)) - Number(DiscoveredModels.isLatestPointer(left.model.id));
271
+ if (byPointer !== 0) {
272
+ return byPointer;
273
+ }
274
+ const byVersion = DiscoveredModels.compareByVersion(left.model.id, right.model.id);
275
+ return byVersion !== 0 ? byVersion : left.index - right.index;
276
+ }
277
+
278
+ /**
279
+ * Drops the marked and hidden ids of a provider that a successful discovery no longer offers, so
280
+ * that neither list accumulates models that no longer exist. Only a live discovery prunes: a
281
+ * cached result, a failure or a missing credential says nothing about what the provider offers.
282
+ */
283
+ protected pruneFavorites(status: ModelDiscoveryStatus): void {
284
+ if (status.state !== 'ready' || status.fromCache || !status.discovered) {
285
+ return;
286
+ }
287
+ const prefix = status.modelIdPrefix ?? status.providerId;
288
+ const offered = new Set(status.discovered.map(model => `${prefix}/${model.id}`));
289
+ for (const preferenceName of [PREFERENCE_NAME_FAVORITE_MODELS, PREFERENCE_NAME_HIDDEN_MODELS]) {
290
+ const ids = this.preferenceService.get<string[]>(preferenceName, []);
291
+ const kept = ids.filter(id => !id.startsWith(`${prefix}/`) || offered.has(id));
292
+ if (kept.length !== ids.length) {
293
+ this.write(preferenceName, kept);
294
+ }
295
+ }
296
+ }
297
+ }
@@ -28,6 +28,9 @@ export * from './ai-view-contribution';
28
28
  export * from './frontend-language-model-registry';
29
29
  export * from './frontend-language-model-alias-registry';
30
30
  export * from './hide-ai-preferences-contribution';
31
+ export * from './model-discovery-status-service';
32
+ export * from './favorite-models-service';
33
+ export * from './discovering-provider-contribution';
31
34
  export * from './frontend-variable-service';
32
35
  export * from './prompttemplate-contribution';
33
36
  export * from './theia-variable-contribution';
@@ -0,0 +1,95 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 EclipseSource GmbH.
3
+ //
4
+ // This program and the accompanying materials are made available under the
5
+ // terms of the Eclipse Public License v. 2.0 which is available at
6
+ // http://www.eclipse.org/legal/epl-2.0.
7
+ //
8
+ // This Source Code may also be made available under the following Secondary
9
+ // Licenses when the conditions for such availability set forth in the Eclipse
10
+ // Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
+ // with the GNU Classpath Exception which is available at
12
+ // https://www.gnu.org/software/classpath/license.html.
13
+ //
14
+ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
+ // *****************************************************************************
16
+
17
+ import { expect } from 'chai';
18
+ import { ModelDiscoveryStatus } from '../common/model-discovery-status';
19
+ import { ModelDiscoveryStatusService } from './model-discovery-status-service';
20
+
21
+ describe('ModelDiscoveryStatusService', () => {
22
+
23
+ it('registers a provider with an initial idle status', () => {
24
+ const service = new ModelDiscoveryStatusService();
25
+ service.registerProvider({ providerId: 'anthropic', label: 'Anthropic', refresh: async () => { } });
26
+ const status = service.getStatus('anthropic');
27
+ expect(status?.state).to.equal('idle');
28
+ expect(status?.label).to.equal('Anthropic');
29
+ });
30
+
31
+ it('merges partial updates and fires onDidChange', () => {
32
+ const service = new ModelDiscoveryStatusService();
33
+ service.registerProvider({ providerId: 'anthropic', label: 'Anthropic', refresh: async () => { } });
34
+
35
+ const events: ModelDiscoveryStatus[] = [];
36
+ service.onDidChange(e => events.push(e));
37
+
38
+ service.updateStatus('anthropic', { state: 'fetching' });
39
+ service.updateStatus('anthropic', { state: 'ready', lastFetch: 123, fromCache: false });
40
+
41
+ const status = service.getStatus('anthropic');
42
+ expect(status?.state).to.equal('ready');
43
+ expect(status?.lastFetch).to.equal(123);
44
+ // label is carried over from registration.
45
+ expect(status?.label).to.equal('Anthropic');
46
+ expect(events.map(e => e.state)).to.deep.equal(['fetching', 'ready']);
47
+ });
48
+
49
+ it('does not reset an existing status when the provider is re-registered', () => {
50
+ const service = new ModelDiscoveryStatusService();
51
+ service.registerProvider({ providerId: 'anthropic', label: 'Anthropic', refresh: async () => { } });
52
+ service.updateStatus('anthropic', { state: 'ready' });
53
+
54
+ service.registerProvider({ providerId: 'anthropic', label: 'Anthropic', refresh: async () => { } });
55
+ expect(service.getStatus('anthropic')?.state).to.equal('ready');
56
+ });
57
+
58
+ it('reports a failure and clears what the previous state offered', () => {
59
+ const service = new ModelDiscoveryStatusService();
60
+ service.registerProvider({ providerId: 'copilot', label: 'GitHub Copilot', refresh: async () => { } });
61
+ service.updateStatus('copilot', {
62
+ state: 'no-credentials',
63
+ stateLabel: 'Not signed in',
64
+ action: { label: 'Sign in', commandId: 'copilot.signIn' }
65
+ });
66
+
67
+ service.reportError('copilot', new Error('the CLI is not talking'));
68
+
69
+ const status = service.getStatus('copilot');
70
+ expect(status?.state).to.equal('error');
71
+ expect(status?.message).to.equal('the CLI is not talking');
72
+ // A failure must not keep reading like the state before it.
73
+ expect(status?.stateLabel).to.equal(undefined);
74
+ expect(status?.action).to.equal(undefined);
75
+ });
76
+
77
+ it('reports a rejection that is not an error', () => {
78
+ const service = new ModelDiscoveryStatusService();
79
+ service.registerProvider({ providerId: 'anthropic', label: 'Anthropic', refresh: async () => { } });
80
+ service.reportError('anthropic', 'nope');
81
+ expect(service.getStatus('anthropic')?.message).to.equal('nope');
82
+ });
83
+
84
+ it('delegates refresh to the registered provider', async () => {
85
+ const service = new ModelDiscoveryStatusService();
86
+ let refreshed = false;
87
+ service.registerProvider({ providerId: 'anthropic', label: 'Anthropic', refresh: async () => { refreshed = true; } });
88
+
89
+ await service.refresh('anthropic');
90
+ expect(refreshed).to.be.true;
91
+
92
+ // Refreshing an unknown provider is a no-op and does not throw.
93
+ await service.refresh('unknown');
94
+ });
95
+ });
@@ -0,0 +1,118 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 EclipseSource GmbH.
3
+ //
4
+ // This program and the accompanying materials are made available under the
5
+ // terms of the Eclipse Public License v. 2.0 which is available at
6
+ // http://www.eclipse.org/legal/epl-2.0.
7
+ //
8
+ // This Source Code may also be made available under the following Secondary
9
+ // Licenses when the conditions for such availability set forth in the Eclipse
10
+ // Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
+ // with the GNU Classpath Exception which is available at
12
+ // https://www.gnu.org/software/classpath/license.html.
13
+ //
14
+ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
+ // *****************************************************************************
16
+
17
+ import { Emitter, Event } from '@theia/core';
18
+ import { injectable } from '@theia/core/shared/inversify';
19
+ import { ModelDiscoveryStatus } from '../common/model-discovery-status';
20
+
21
+ /**
22
+ * A provider that participates in dynamic model discovery. Provider frontend contributions
23
+ * register a descriptor so the UI can display their {@link ModelDiscoveryStatus} and trigger
24
+ * a manual refresh.
25
+ */
26
+ export interface ModelDiscoveryProviderDescriptor {
27
+ readonly providerId: string;
28
+ readonly label: string;
29
+ /** Prefix of this provider's registered model ids, if it differs from {@link providerId}. */
30
+ readonly modelIdPrefix?: string;
31
+ /** Triggers a manual re-fetch (used by the "Refresh"/"Retry" action). */
32
+ refresh(): Promise<void>;
33
+ }
34
+
35
+ /**
36
+ * Frontend registry of provider-level model-discovery status.
37
+ *
38
+ * Provider frontend contributions push their state here as they fetch models; the AI
39
+ * configuration UI observes {@link onDidChange} to render fetching/error/no-credentials state.
40
+ * This is the provider-level counterpart to the per-model `LanguageModelStatus`.
41
+ */
42
+ @injectable()
43
+ export class ModelDiscoveryStatusService {
44
+
45
+ protected readonly descriptors = new Map<string, ModelDiscoveryProviderDescriptor>();
46
+ protected readonly statuses = new Map<string, ModelDiscoveryStatus>();
47
+
48
+ protected readonly onDidChangeEmitter = new Emitter<ModelDiscoveryStatus>();
49
+ /** Fired whenever a provider's status changes; the argument is the new status. */
50
+ readonly onDidChange: Event<ModelDiscoveryStatus> = this.onDidChangeEmitter.event;
51
+
52
+ /**
53
+ * Register a provider. Idempotent: re-registering keeps any existing status and only
54
+ * updates the refresh callback and label.
55
+ */
56
+ registerProvider(descriptor: ModelDiscoveryProviderDescriptor): void {
57
+ this.descriptors.set(descriptor.providerId, descriptor);
58
+ if (!this.statuses.has(descriptor.providerId)) {
59
+ this.statuses.set(descriptor.providerId, {
60
+ providerId: descriptor.providerId,
61
+ label: descriptor.label,
62
+ modelIdPrefix: descriptor.modelIdPrefix,
63
+ state: 'idle'
64
+ });
65
+ }
66
+ this.onDidChangeEmitter.fire(this.statuses.get(descriptor.providerId)!);
67
+ }
68
+
69
+ /**
70
+ * Update a provider's status. Only the provided fields are changed; the rest are carried
71
+ * over from the previous status. Passing `undefined` for an optional field explicitly clears it.
72
+ */
73
+ updateStatus(providerId: string, update: Partial<Omit<ModelDiscoveryStatus, 'providerId'>>): void {
74
+ const previous = this.statuses.get(providerId);
75
+ const next: ModelDiscoveryStatus = {
76
+ providerId,
77
+ label: update.label ?? previous?.label ?? providerId,
78
+ modelIdPrefix: 'modelIdPrefix' in update ? update.modelIdPrefix : previous?.modelIdPrefix,
79
+ state: update.state ?? previous?.state ?? 'idle',
80
+ stateLabel: 'stateLabel' in update ? update.stateLabel : previous?.stateLabel,
81
+ message: 'message' in update ? update.message : previous?.message,
82
+ lastFetch: 'lastFetch' in update ? update.lastFetch : previous?.lastFetch,
83
+ fromCache: 'fromCache' in update ? update.fromCache : previous?.fromCache,
84
+ discovered: 'discovered' in update ? update.discovered : previous?.discovered,
85
+ action: 'action' in update ? update.action : previous?.action
86
+ };
87
+ this.statuses.set(providerId, next);
88
+ this.onDidChangeEmitter.fire(next);
89
+ }
90
+
91
+ /**
92
+ * Reports a failed discovery run. Any step of a run can fail, including the ones that only talk to
93
+ * the backend, and nothing awaits the promise a rejection would travel on: a provider's startup
94
+ * does not, and a refresh button cannot act on it. What the previous state offered is cleared, so
95
+ * a failure does not keep reading like the state before it (a badge, a sign-in button).
96
+ */
97
+ reportError(providerId: string, error: unknown): void {
98
+ this.updateStatus(providerId, {
99
+ state: 'error',
100
+ stateLabel: undefined,
101
+ message: error instanceof Error ? error.message : String(error),
102
+ action: undefined
103
+ });
104
+ }
105
+
106
+ getStatus(providerId: string): ModelDiscoveryStatus | undefined {
107
+ return this.statuses.get(providerId);
108
+ }
109
+
110
+ getStatuses(): ModelDiscoveryStatus[] {
111
+ return [...this.statuses.values()];
112
+ }
113
+
114
+ /** Trigger a manual refresh for the given provider, if it is registered. */
115
+ async refresh(providerId: string): Promise<void> {
116
+ await this.descriptors.get(providerId)?.refresh();
117
+ }
118
+ }
@@ -38,6 +38,15 @@ export const AI_CORE_PREFERENCES_TITLE = nls.localize('theia/ai-core/preferences
38
38
  */
39
39
  export const MODEL_PROVIDER_TYPE_DETAIL = 'aiModelProvider';
40
40
 
41
+ /**
42
+ * Key under a preference's schema `typeDetails` marking a preference that the AI Configuration view
43
+ * edits through a dedicated control of its own, so it should not also appear as a raw settings row.
44
+ * The value stays a plain preference, editable in `settings.json` like any other; it is only the
45
+ * duplicate, less legible editor that is left out. Declare it as
46
+ * `typeDetails: { [DEDICATED_EDITOR_TYPE_DETAIL]: true }`.
47
+ */
48
+ export const DEDICATED_EDITOR_TYPE_DETAIL = 'aiDedicatedEditor';
49
+
41
50
  /**
42
51
  * Shape a language-model provider stores under a preference's `typeDetails[MODEL_PROVIDER_TYPE_DETAIL]`
43
52
  * to describe its `ai-features.<provider>.*` block to the AI Configuration view's Models page. Declared
@@ -51,6 +60,8 @@ export const PREFERENCE_NAME_PROMPT_TEMPLATES = 'ai-features.promptTemplates.pro
51
60
  export const PREFERENCE_NAME_REQUEST_SETTINGS = 'ai-features.modelSettings.requestSettings';
52
61
  export const PREFERENCE_NAME_REASONING = 'ai-features.reasoning.defaults';
53
62
  export const PREFERENCE_NAME_MAX_RETRIES = 'ai-features.modelSettings.maxRetries';
63
+ export const PREFERENCE_NAME_FAVORITE_MODELS = 'ai-features.modelSettings.favoriteModels';
64
+ export const PREFERENCE_NAME_HIDDEN_MODELS = 'ai-features.modelSettings.hiddenModels';
54
65
  export const PREFERENCE_NAME_DEFAULT_NOTIFICATION_TYPE = 'ai-features.notifications.default';
55
66
  export const PREFERENCE_NAME_SKILL_DIRECTORIES = 'ai-features.skills.skillDirectories';
56
67
  export const PREFERENCE_NAME_SERVER_SIDE_COMPACTION = 'ai-features.chat.serverSideCompaction';
@@ -169,6 +180,34 @@ export const aiCorePreferenceSchema: PreferenceSchema = {
169
180
  minimum: 0,
170
181
  default: 3
171
182
  },
183
+ [PREFERENCE_NAME_FAVORITE_MODELS]: {
184
+ title: nls.localize('theia/ai/core/favoriteModels/title', 'Favorite Models'),
185
+ markdownDescription: nls.localize('theia/ai/core/favoriteModels/mdDescription',
186
+ 'Models the AI chat input\'s model picker shows in addition to the newest ones of each provider, as fully qualified ids '
187
+ + '(e.g. `anthropic/claude-opus-5`). This is for keeping an older or release-pinned model at hand. Check a model on its '
188
+ + 'provider\'s page in the AI Configuration view to add it.'),
189
+ type: 'array',
190
+ // The provider pages check models one by one; a second, raw editor for the same ids would
191
+ // invite typos and disagree with what the checks say.
192
+ typeDetails: { [DEDICATED_EDITOR_TYPE_DETAIL]: true },
193
+ items: {
194
+ type: 'string'
195
+ },
196
+ default: []
197
+ },
198
+ [PREFERENCE_NAME_HIDDEN_MODELS]: {
199
+ title: nls.localizeByDefault('Hidden Models'),
200
+ markdownDescription: nls.localize('theia/ai/core/hiddenModels/mdDescription',
201
+ 'Models the AI chat input\'s model picker does not show even though it would by default, as fully qualified ids '
202
+ + '(e.g. `google/gemini-flash-lite-latest`). Only the models shown without being listed as a favorite end up here, '
203
+ + 'i.e. the newest ones of a provider. Uncheck a model on its provider\'s page in the AI Configuration view to add it.'),
204
+ type: 'array',
205
+ typeDetails: { [DEDICATED_EDITOR_TYPE_DETAIL]: true },
206
+ items: {
207
+ type: 'string'
208
+ },
209
+ default: []
210
+ },
172
211
  [PREFERENCE_NAME_DEFAULT_NOTIFICATION_TYPE]: {
173
212
  title: nls.localize('theia/ai/core/defaultNotification/title', 'Default Notification Type'),
174
213
  markdownDescription: nls.localize('theia/ai/core/defaultNotification/mdDescription',
@@ -276,6 +315,7 @@ export interface AICoreConfiguration {
276
315
  [PREFERENCE_NAME_REQUEST_SETTINGS]: Array<RequestSetting> | undefined;
277
316
  [PREFERENCE_NAME_REASONING]: Array<ReasoningPreferenceEntry> | undefined;
278
317
  [PREFERENCE_NAME_MAX_RETRIES]: number | undefined;
318
+ [PREFERENCE_NAME_FAVORITE_MODELS]: string[] | undefined;
279
319
  [PREFERENCE_NAME_DEFAULT_NOTIFICATION_TYPE]: NotificationType | undefined;
280
320
  [PREFERENCE_NAME_SKILL_DIRECTORIES]: string[] | undefined;
281
321
  [PREFERENCE_NAME_SERVER_SIDE_COMPACTION]: boolean | undefined;
@@ -22,6 +22,8 @@ export * from './language-model-delegate';
22
22
  export * from './language-model-util';
23
23
  export * from './language-model';
24
24
  export * from './language-model-alias';
25
+ export * from './model-discovery-status';
26
+ export * from './model-discovery-util';
25
27
  export * from './prompt-service';
26
28
  export * from './prompt-service-util';
27
29
  export * from './proxy-util';