@theia/ai-core 1.76.0-next.6 → 1.76.0

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 (128) 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 +233 -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/frontend-language-model-alias-registry.js +1 -1
  21. package/lib/browser/frontend-language-model-service.d.ts.map +1 -1
  22. package/lib/browser/frontend-language-model-service.js +8 -0
  23. package/lib/browser/frontend-language-model-service.js.map +1 -1
  24. package/lib/browser/frontend-language-model-service.spec.d.ts +2 -0
  25. package/lib/browser/frontend-language-model-service.spec.d.ts.map +1 -0
  26. package/lib/browser/frontend-language-model-service.spec.js +76 -0
  27. package/lib/browser/frontend-language-model-service.spec.js.map +1 -0
  28. package/lib/browser/index.d.ts +3 -0
  29. package/lib/browser/index.d.ts.map +1 -1
  30. package/lib/browser/index.js +3 -0
  31. package/lib/browser/index.js.map +1 -1
  32. package/lib/browser/model-discovery-status-service.d.ts +52 -0
  33. package/lib/browser/model-discovery-status-service.d.ts.map +1 -0
  34. package/lib/browser/model-discovery-status-service.js +110 -0
  35. package/lib/browser/model-discovery-status-service.js.map +1 -0
  36. package/lib/browser/model-discovery-status-service.spec.d.ts +2 -0
  37. package/lib/browser/model-discovery-status-service.spec.d.ts.map +1 -0
  38. package/lib/browser/model-discovery-status-service.spec.js +86 -0
  39. package/lib/browser/model-discovery-status-service.spec.js.map +1 -0
  40. package/lib/common/ai-core-preferences.d.ts +11 -0
  41. package/lib/common/ai-core-preferences.d.ts.map +1 -1
  42. package/lib/common/ai-core-preferences.js +37 -1
  43. package/lib/common/ai-core-preferences.js.map +1 -1
  44. package/lib/common/index.d.ts +2 -0
  45. package/lib/common/index.d.ts.map +1 -1
  46. package/lib/common/index.js +2 -0
  47. package/lib/common/index.js.map +1 -1
  48. package/lib/common/language-model-ordering.spec.d.ts +2 -0
  49. package/lib/common/language-model-ordering.spec.d.ts.map +1 -0
  50. package/lib/common/language-model-ordering.spec.js +72 -0
  51. package/lib/common/language-model-ordering.spec.js.map +1 -0
  52. package/lib/common/language-model-util.d.ts +17 -1
  53. package/lib/common/language-model-util.d.ts.map +1 -1
  54. package/lib/common/language-model-util.js +44 -1
  55. package/lib/common/language-model-util.js.map +1 -1
  56. package/lib/common/language-model.d.ts +21 -0
  57. package/lib/common/language-model.d.ts.map +1 -1
  58. package/lib/common/language-model.js +50 -1
  59. package/lib/common/language-model.js.map +1 -1
  60. package/lib/common/language-model.spec.js +36 -0
  61. package/lib/common/language-model.spec.js.map +1 -1
  62. package/lib/common/model-discovery-status.d.ts +112 -0
  63. package/lib/common/model-discovery-status.d.ts.map +1 -0
  64. package/lib/common/model-discovery-status.js +18 -0
  65. package/lib/common/model-discovery-status.js.map +1 -0
  66. package/lib/common/model-discovery-util.d.ts +46 -0
  67. package/lib/common/model-discovery-util.d.ts.map +1 -0
  68. package/lib/common/model-discovery-util.js +128 -0
  69. package/lib/common/model-discovery-util.js.map +1 -0
  70. package/lib/common/model-discovery-util.spec.d.ts +2 -0
  71. package/lib/common/model-discovery-util.spec.d.ts.map +1 -0
  72. package/lib/common/model-discovery-util.spec.js +95 -0
  73. package/lib/common/model-discovery-util.spec.js.map +1 -0
  74. package/lib/common/prompt-service.js +3 -3
  75. package/lib/common/prompt-service.js.map +1 -1
  76. package/lib/common/prompt-service.spec.js +5 -0
  77. package/lib/common/prompt-service.spec.js.map +1 -1
  78. package/lib/node/ai-core-backend-module.d.ts.map +1 -1
  79. package/lib/node/ai-core-backend-module.js +8 -0
  80. package/lib/node/ai-core-backend-module.js.map +1 -1
  81. package/lib/node/backend-language-model-registry.d.ts.map +1 -1
  82. package/lib/node/backend-language-model-registry.js +1 -0
  83. package/lib/node/backend-language-model-registry.js.map +1 -1
  84. package/lib/node/index.d.ts +2 -0
  85. package/lib/node/index.d.ts.map +1 -1
  86. package/lib/node/index.js +2 -0
  87. package/lib/node/index.js.map +1 -1
  88. package/lib/node/model-discovery-fetcher.d.ts +43 -0
  89. package/lib/node/model-discovery-fetcher.d.ts.map +1 -0
  90. package/lib/node/model-discovery-fetcher.js +85 -0
  91. package/lib/node/model-discovery-fetcher.js.map +1 -0
  92. package/lib/node/model-snapshot-store.d.ts +26 -0
  93. package/lib/node/model-snapshot-store.d.ts.map +1 -0
  94. package/lib/node/model-snapshot-store.js +73 -0
  95. package/lib/node/model-snapshot-store.js.map +1 -0
  96. package/lib/node/test/test-model-discovery-fetcher.d.ts +21 -0
  97. package/lib/node/test/test-model-discovery-fetcher.d.ts.map +1 -0
  98. package/lib/node/test/test-model-discovery-fetcher.js +54 -0
  99. package/lib/node/test/test-model-discovery-fetcher.js.map +1 -0
  100. package/package.json +12 -12
  101. package/src/browser/ai-core-frontend-module.ts +5 -0
  102. package/src/browser/discovering-provider-contribution.spec.ts +304 -0
  103. package/src/browser/discovering-provider-contribution.ts +241 -0
  104. package/src/browser/favorite-models-service.spec.ts +336 -0
  105. package/src/browser/favorite-models-service.ts +297 -0
  106. package/src/browser/frontend-language-model-alias-registry.ts +1 -1
  107. package/src/browser/frontend-language-model-service.spec.ts +86 -0
  108. package/src/browser/frontend-language-model-service.ts +9 -1
  109. package/src/browser/index.ts +3 -0
  110. package/src/browser/model-discovery-status-service.spec.ts +101 -0
  111. package/src/browser/model-discovery-status-service.ts +123 -0
  112. package/src/common/ai-core-preferences.ts +40 -0
  113. package/src/common/index.ts +2 -0
  114. package/src/common/language-model-ordering.spec.ts +83 -0
  115. package/src/common/language-model-util.ts +43 -0
  116. package/src/common/language-model.spec.ts +45 -0
  117. package/src/common/language-model.ts +54 -0
  118. package/src/common/model-discovery-status.ts +132 -0
  119. package/src/common/model-discovery-util.spec.ts +105 -0
  120. package/src/common/model-discovery-util.ts +129 -0
  121. package/src/common/prompt-service.spec.ts +6 -0
  122. package/src/common/prompt-service.ts +3 -3
  123. package/src/node/ai-core-backend-module.ts +8 -0
  124. package/src/node/backend-language-model-registry.ts +1 -0
  125. package/src/node/index.ts +2 -0
  126. package/src/node/model-discovery-fetcher.ts +105 -0
  127. package/src/node/model-snapshot-store.ts +80 -0
  128. package/src/node/test/test-model-discovery-fetcher.ts +62 -0
@@ -0,0 +1,336 @@
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 { PreferenceService } from '@theia/core';
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 { FavoriteModelsService } from './favorite-models-service';
22
+ import { ModelDiscoveryStatusService } from './model-discovery-status-service';
23
+
24
+ function dated(id: string, released: string): DiscoveredModel {
25
+ return { id, released: Date.parse(released) };
26
+ }
27
+
28
+ function createService(
29
+ statuses: ModelDiscoveryStatus[],
30
+ marked: string[] = [],
31
+ hidden: string[] = []
32
+ ): { service: FavoriteModelsService; written: string[][]; stored: Record<string, string[]> } {
33
+ const service = new FavoriteModelsService();
34
+ const written: string[][] = [];
35
+ const stored: Record<string, string[]> = {
36
+ [PREFERENCE_NAME_FAVORITE_MODELS]: marked,
37
+ [PREFERENCE_NAME_HIDDEN_MODELS]: hidden
38
+ };
39
+ const discoveryStatus: Partial<ModelDiscoveryStatusService> = { getStatuses: () => statuses };
40
+ const preferenceService: Partial<PreferenceService> = {
41
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
42
+ get: ((name: string, defaultValue: unknown) => stored[name] ?? defaultValue) as any,
43
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
44
+ set: (async (name: string, value: any) => { stored[name] = value; written.push(value); }) as any
45
+ };
46
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
47
+ (service as any).discoveryStatus = discoveryStatus;
48
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
49
+ (service as any).preferenceService = preferenceService;
50
+ return { service, written, stored };
51
+ }
52
+
53
+ function status(overrides: Partial<ModelDiscoveryStatus> = {}): ModelDiscoveryStatus {
54
+ return { providerId: 'anthropic', label: 'Anthropic', state: 'ready', ...overrides };
55
+ }
56
+
57
+ describe('FavoriteModelsService', () => {
58
+
59
+ it('features the five most recently released undated models, never a release-pinned one', () => {
60
+ const { service } = createService([status({
61
+ discovered: [
62
+ dated('claude-opus-5', '2026-04-01'),
63
+ dated('claude-sonnet-5', '2026-02-01'),
64
+ dated('claude-haiku-4-5', '2025-10-01'),
65
+ dated('claude-fable-5', '2025-09-01'),
66
+ dated('claude-opus-4-8', '2025-08-01'),
67
+ dated('claude-opus-4-7', '2025-06-01'),
68
+ dated('claude-opus-5-20260401', '2026-04-01')
69
+ ]
70
+ })]);
71
+ expect(service.isFeatured('anthropic/claude-opus-5')).to.equal(true);
72
+ expect(service.isFeatured('anthropic/claude-opus-4-8')).to.equal(true);
73
+ // The sixth-newest falls outside the featured set.
74
+ expect(service.isFeatured('anthropic/claude-opus-4-7')).to.equal(false);
75
+ // A release-pinned id is never featured, however recent it is.
76
+ expect(service.isFeatured('anthropic/claude-opus-5-20260401')).to.equal(false);
77
+ });
78
+
79
+ it('caps a provider that reports no release dates by the versions in its ids', () => {
80
+ const { service } = createService([status({
81
+ providerId: 'google',
82
+ discovered: [
83
+ { id: 'gemini-2.0-pro' }, { id: 'gemini-2.5-flash' }, { id: 'gemini-2.5-pro' },
84
+ { id: 'gemini-3.1-pro-preview' }, { id: 'gemini-3.5-flash' }, { id: 'gemini-3.7-flash' }
85
+ ]
86
+ })]);
87
+ // The highest versions in the ids; the oldest of the six drops out.
88
+ expect(service.isFeatured('google/gemini-3.7-flash')).to.equal(true);
89
+ expect(service.isFeatured('google/gemini-2.5-pro')).to.equal(true);
90
+ expect(service.isFeatured('google/gemini-2.0-pro')).to.equal(false);
91
+ });
92
+
93
+ it('features a `-latest` pointer ahead of the versioned ids it stands for', () => {
94
+ const { service } = createService([status({
95
+ providerId: 'google',
96
+ discovered: [
97
+ { id: 'gemini-3.5-flash' }, { id: 'gemini-3.7-flash' }, { id: 'gemini-3.8-flash' },
98
+ { id: 'gemini-3.1-pro-preview' }, { id: 'gemini-2.5-pro' }, { id: 'gemini-flash-latest' }
99
+ ]
100
+ })]);
101
+ // The pointer carries no version, so without a rule of its own it would sort behind all of them.
102
+ expect(service.isFeatured('google/gemini-flash-latest')).to.equal(true);
103
+ expect(service.isFeatured('google/gemini-2.5-pro')).to.equal(false);
104
+ });
105
+
106
+ it('offers the pointers and the newest versions for a provider that reports no dates', () => {
107
+ const { service } = createService([status({
108
+ providerId: 'google',
109
+ discovered: [
110
+ { id: 'gemini-flash-latest' }, { id: 'gemini-flash-lite-latest' }, { id: 'gemini-pro-latest' },
111
+ { id: 'gemini-3.8-flash' }, { id: 'gemini-3.7-flash' }, { id: 'gemini-3.5-flash' },
112
+ { id: 'gemini-3.1-pro-preview' }, { id: 'gemini-2.5-pro' }
113
+ ]
114
+ })]);
115
+ expect(service.isFeatured('google/gemini-pro-latest')).to.equal(true);
116
+ expect(service.isFeatured('google/gemini-flash-latest')).to.equal(true);
117
+ expect(service.isFeatured('google/gemini-flash-lite-latest')).to.equal(true);
118
+ // The pointers first, then the newest concrete models fill the rest.
119
+ expect(service.isFeatured('google/gemini-3.8-flash')).to.equal(true);
120
+ expect(service.isFeatured('google/gemini-3.7-flash')).to.equal(true);
121
+ expect(service.isFeatured('google/gemini-3.1-pro-preview')).to.equal(false);
122
+ });
123
+
124
+ it('orders a `-latest` id by its release date where the provider reports one', () => {
125
+ const { service } = createService([status({
126
+ providerId: 'openAiOfficial',
127
+ modelIdPrefix: 'openai',
128
+ discovered: [
129
+ dated('gpt-5.6-sol', '2026-02-01'),
130
+ dated('gpt-5.5', '2025-11-01'),
131
+ dated('gpt-5.1', '2025-08-01'),
132
+ dated('gpt-5', '2025-06-01'),
133
+ dated('o4-mini', '2025-04-01'),
134
+ dated('chatgpt-4o-latest', '2024-08-01')
135
+ ]
136
+ })]);
137
+ // A provider that dates its models dates its pointers too, so the pointer rule stays out of it.
138
+ expect(service.isFeatured('openai/gpt-5.6-sol')).to.equal(true);
139
+ expect(service.isFeatured('openai/o4-mini')).to.equal(true);
140
+ expect(service.isFeatured('openai/chatgpt-4o-latest')).to.equal(false);
141
+ });
142
+
143
+ it('features what the provider reports as newest, whatever the model aliases name', () => {
144
+ const { service } = createService([status({
145
+ discovered: [
146
+ dated('claude-opus-5', '2026-04-01'),
147
+ dated('claude-sonnet-5', '2026-03-01'),
148
+ dated('claude-fable-5', '2026-02-01'),
149
+ dated('claude-opus-4-8', '2026-01-01'),
150
+ dated('claude-opus-4-7', '2025-12-01'),
151
+ // Named by the built-in aliases, and the oldest of the six: an alias is a curated list of
152
+ // its own, saying which model a purpose uses rather than which are worth offering here.
153
+ dated('claude-haiku-4-5', '2025-10-01')
154
+ ]
155
+ })]);
156
+ expect(service.isFeatured('anthropic/claude-opus-4-7')).to.equal(true);
157
+ expect(service.isFeatured('anthropic/claude-haiku-4-5')).to.equal(false);
158
+ });
159
+
160
+ it('prunes the marked and hidden models that a successful discovery no longer offers', () => {
161
+ const { service, stored } = createService(
162
+ [status({ discovered: [dated('claude-opus-5', '2026-04-01')] })],
163
+ ['anthropic/claude-gone', 'openai/gpt-5.5'],
164
+ ['anthropic/claude-also-gone', 'anthropic/claude-opus-5']
165
+ );
166
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
167
+ (service as any).pruneFavorites(status({ discovered: [dated('claude-opus-5', '2026-04-01')] }));
168
+ // Only the vanished models of that provider are dropped; another provider's stars are untouched.
169
+ expect(stored[PREFERENCE_NAME_FAVORITE_MODELS]).to.deep.equal(['openai/gpt-5.5']);
170
+ expect(stored[PREFERENCE_NAME_HIDDEN_MODELS]).to.deep.equal(['anthropic/claude-opus-5']);
171
+ });
172
+
173
+ it('keeps the marked models when the discovery did not actually reach the provider', () => {
174
+ const { service, written } = createService([], ['anthropic/claude-gone']);
175
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
176
+ const prune = (candidate: ModelDiscoveryStatus) => (service as any).pruneFavorites(candidate);
177
+ prune(status({ state: 'ready', fromCache: true, discovered: [] }));
178
+ prune(status({ state: 'error' }));
179
+ prune(status({ state: 'no-credentials' }));
180
+ prune(status({ state: 'ready', discovered: undefined }));
181
+ expect(written).to.be.empty;
182
+ });
183
+
184
+ it('respects a provider that nominates its own models, as Copilot does across vendors', () => {
185
+ const { service } = createService([status({
186
+ providerId: 'copilot',
187
+ discovered: [
188
+ { id: 'auto', featured: true },
189
+ { id: 'gpt-5', featured: true },
190
+ { id: 'claude-sonnet-4.5', featured: true },
191
+ { id: 'gpt-4.1' },
192
+ { id: 'claude-haiku-4' },
193
+ { id: 'gemini-2.5-pro' }
194
+ ]
195
+ })]);
196
+ expect(service.isFeatured('copilot/auto')).to.equal(true);
197
+ expect(service.isFeatured('copilot/claude-sonnet-4.5')).to.equal(true);
198
+ // Not nominated, and the generic "five newest" ranking does not apply where a provider nominated.
199
+ expect(service.isFeatured('copilot/gpt-4.1')).to.equal(false);
200
+ expect(service.isFeatured('copilot/gemini-2.5-pro')).to.equal(false);
201
+ });
202
+
203
+ it('offers exactly what a nominating provider nominated, and nothing beside it', () => {
204
+ const { service } = createService([status({
205
+ providerId: 'copilot',
206
+ discovered: [{ id: 'gpt-5', featured: true }, { id: 'claude-haiku-4' }]
207
+ })]);
208
+ expect(service.isFeatured('copilot/gpt-5')).to.equal(true);
209
+ expect(service.isFeatured('copilot/claude-haiku-4')).to.equal(false);
210
+ });
211
+
212
+ it('features a manually configured list in full', () => {
213
+ const { service } = createService([status({
214
+ state: 'overridden',
215
+ discovered: [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }, { id: 'e' }, { id: 'f' }]
216
+ })]);
217
+ expect(service.isFeatured('anthropic/f')).to.equal(true);
218
+ });
219
+
220
+ it('honours the models the user marked', () => {
221
+ const { service } = createService(
222
+ [status({ discovered: [dated('claude-opus-5', '2026-04-01'), dated('claude-opus-4-7', '2024-06-01')] })],
223
+ ['anthropic/claude-opus-5-20260401']
224
+ );
225
+ expect(service.isFavorite('anthropic/claude-opus-5-20260401')).to.equal(true);
226
+ expect(service.isFavorite('anthropic/claude-opus-4-7')).to.equal(true); // featured, only two undated ids
227
+ expect(service.isFavorite('anthropic/claude-unknown')).to.equal(false);
228
+ });
229
+
230
+ it('treats the models of a provider that does not participate in discovery as favorites', () => {
231
+ const { service } = createService([status({ discovered: [dated('claude-opus-5', '2026-04-01')] })]);
232
+ // Manually configured providers (ollama, hugging face, …) and custom endpoints are the user's own list.
233
+ expect(service.isFavorite('ollama/llama3')).to.equal(true);
234
+ expect(service.isFavorite('my-custom-endpoint-model')).to.equal(true);
235
+ });
236
+
237
+ it('honours the model-id prefix when it differs from the provider id', () => {
238
+ const { service } = createService([status({
239
+ providerId: 'openAiOfficial',
240
+ modelIdPrefix: 'openai',
241
+ discovered: [dated('gpt-5.6-sol', '2026-02-01')]
242
+ })]);
243
+ expect(service.isFeatured('openai/gpt-5.6-sol')).to.equal(true);
244
+ });
245
+
246
+ describe('resetToDefaults', () => {
247
+
248
+ it('drops what the user decided about one provider and leaves the others alone', async () => {
249
+ const { service, stored } = createService(
250
+ [status({ discovered: [dated('claude-opus-5', '2026-04-01')] })],
251
+ ['anthropic/claude-opus-4-7', 'openai/gpt-5.5'],
252
+ ['anthropic/claude-opus-5', 'openai/gpt-5.6-sol']
253
+ );
254
+ expect(service.hasOverrides('anthropic')).to.equal(true);
255
+
256
+ await service.resetToDefaults('anthropic');
257
+
258
+ expect(stored[PREFERENCE_NAME_FAVORITE_MODELS]).to.deep.equal(['openai/gpt-5.5']);
259
+ expect(stored[PREFERENCE_NAME_HIDDEN_MODELS]).to.deep.equal(['openai/gpt-5.6-sol']);
260
+ expect(service.hasOverrides('anthropic')).to.equal(false);
261
+ expect(service.hasOverrides('openai')).to.equal(true);
262
+ // The provider is back to what it offers by default.
263
+ expect(service.isFavorite('anthropic/claude-opus-5')).to.equal(true);
264
+ });
265
+
266
+ it('writes nothing when there is nothing to reset', async () => {
267
+ const { service, written } = createService([status({ discovered: [dated('claude-opus-5', '2026-04-01')] })]);
268
+ expect(service.hasOverrides('anthropic')).to.equal(false);
269
+ await service.resetToDefaults('anthropic');
270
+ expect(written).to.be.empty;
271
+ });
272
+ });
273
+
274
+ describe('toggleFavorite', () => {
275
+
276
+ it('adds and removes an id the user can control', async () => {
277
+ const { service, written } = createService([status({ discovered: [dated('claude-opus-5', '2026-04-01')] })], []);
278
+ await service.toggleFavorite('anthropic/claude-opus-5-20260401');
279
+ expect(written[0]).to.deep.equal(['anthropic/claude-opus-5-20260401']);
280
+
281
+ const removing = createService([status({ discovered: [dated('claude-opus-5', '2026-04-01')] })], ['anthropic/claude-opus-5-20260401']);
282
+ await removing.service.toggleFavorite('anthropic/claude-opus-5-20260401');
283
+ expect(removing.written[0]).to.deep.equal([]);
284
+ });
285
+
286
+ it('hides a model that is offered by default, storing only that', async () => {
287
+ const { service, stored } = createService([status({ discovered: [dated('claude-opus-5', '2026-04-01')] })]);
288
+ expect(service.isFavorite('anthropic/claude-opus-5')).to.equal(true);
289
+
290
+ await service.toggleFavorite('anthropic/claude-opus-5');
291
+
292
+ expect(stored[PREFERENCE_NAME_HIDDEN_MODELS]).to.deep.equal(['anthropic/claude-opus-5']);
293
+ // Featured all the same, it is only kept out of the picker.
294
+ expect(service.isFeatured('anthropic/claude-opus-5')).to.equal(true);
295
+ expect(service.isFavorite('anthropic/claude-opus-5')).to.equal(false);
296
+ // Nothing is written to the favorites: a model offered by default was never listed there.
297
+ expect(stored[PREFERENCE_NAME_FAVORITE_MODELS]).to.be.empty;
298
+ });
299
+
300
+ it('shows a hidden model again by dropping its entry', async () => {
301
+ const { service, stored } = createService(
302
+ [status({ discovered: [dated('claude-opus-5', '2026-04-01')] })],
303
+ [],
304
+ ['anthropic/claude-opus-5']
305
+ );
306
+ expect(service.isFavorite('anthropic/claude-opus-5')).to.equal(false);
307
+
308
+ await service.toggleFavorite('anthropic/claude-opus-5');
309
+
310
+ expect(stored[PREFERENCE_NAME_HIDDEN_MODELS]).to.be.empty;
311
+ expect(stored[PREFERENCE_NAME_FAVORITE_MODELS]).to.be.empty;
312
+ expect(service.isFavorite('anthropic/claude-opus-5')).to.equal(true);
313
+ });
314
+
315
+ it('marks a model that fell out of the featured set while it was hidden', async () => {
316
+ const { service, stored } = createService(
317
+ [status({ discovered: [dated('claude-opus-5', '2026-04-01'), dated('claude-opus-4-7', '2024-06-01')] })],
318
+ [],
319
+ ['anthropic/claude-opus-5-20260401']
320
+ );
321
+ // Release-pinned, so never featured: dropping the entry alone would not bring it back.
322
+ await service.toggleFavorite('anthropic/claude-opus-5-20260401');
323
+
324
+ expect(stored[PREFERENCE_NAME_HIDDEN_MODELS]).to.be.empty;
325
+ expect(stored[PREFERENCE_NAME_FAVORITE_MODELS]).to.deep.equal(['anthropic/claude-opus-5-20260401']);
326
+ expect(service.isFavorite('anthropic/claude-opus-5-20260401')).to.equal(true);
327
+ });
328
+
329
+ it('hides a model of a provider that does not participate in discovery', async () => {
330
+ const { service, stored } = createService([status({ discovered: [dated('claude-opus-5', '2026-04-01')] })]);
331
+ await service.toggleFavorite('ollama/llama3');
332
+ expect(stored[PREFERENCE_NAME_HIDDEN_MODELS]).to.deep.equal(['ollama/llama3']);
333
+ expect(service.isFavorite('ollama/llama3')).to.equal(false);
334
+ });
335
+ });
336
+ });
@@ -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
+ }
@@ -67,7 +67,7 @@ export class DefaultLanguageModelAliasRegistry implements LanguageModelAliasRegi
67
67
  defaultModelIds: [
68
68
  'anthropic/claude-haiku-4-5',
69
69
  'openai/gpt-5.6-luna',
70
- 'google/gemini-3.7-flash'
70
+ 'google/gemini-3.8-flash'
71
71
  ],
72
72
  description: nls.localize('theia/ai/core/defaultModelAliases/fast/description',
73
73
  'Faster and cheaper models for simpler tasks like exploration or basic tool calling, where deep reasoning is not required.')