@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,241 @@
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 { MessageService, nls, PreferenceChange, PreferenceScope, PreferenceService } from '@theia/core';
18
+ import { FrontendApplicationContribution } from '@theia/core/lib/browser';
19
+ import { inject, injectable } from '@theia/core/shared/inversify';
20
+ import { ApiKeySource, DiscoveredModel, ModelDiscoveryResult } from '../common/model-discovery-status';
21
+ import { ModelDiscoveryStatusService } from './model-discovery-status-service';
22
+
23
+ /** The part of a provider's language model manager that a discovery run drives. */
24
+ export interface DiscoveringLanguageModelsManager<T> {
25
+ /** Asks the provider which models it offers; see {@link ModelDiscoveryResult} for the failure cases. */
26
+ fetchAvailableModels(): Promise<ModelDiscoveryResult>;
27
+ /** Where the effective API key comes from, which decides whether a run can proceed. */
28
+ getApiKeySource(): Promise<ApiKeySource>;
29
+ /** Allows or refuses the use of an API key found in the environment. */
30
+ setAllowEnvironmentApiKey(allowed: boolean): void;
31
+ createOrUpdateLanguageModels(...modelDescriptions: T[]): Promise<void>;
32
+ removeLanguageModels(...modelIds: string[]): void;
33
+ }
34
+
35
+ /**
36
+ * What a discovery run tells the user, in the provider's own words. Each message names the provider
37
+ * and its key, so they cannot be shared: a localization key has to be a literal to be extracted.
38
+ */
39
+ export interface ModelDiscoveryMessages {
40
+ /** No credential is configured at all, and where to put one. */
41
+ readonly noCredentials: string;
42
+ /** An API key was found in the environment and waits for the user's confirmation. */
43
+ readonly consentRequired: string;
44
+ /** The question that asks for that confirmation, naming the environment variable it found. */
45
+ readonly consentPrompt: string;
46
+ /** Label of the button that grants it. */
47
+ readonly useEnvironmentKey: string;
48
+ /** The model list is configured by hand, so the provider is not asked what it offers. */
49
+ readonly overridden: string;
50
+ /** Cached models are being served because the last refresh failed with `error`. */
51
+ cached(error: string): string;
52
+ }
53
+
54
+ /**
55
+ * The shared half of a provider that discovers its language models: it asks the provider what it
56
+ * offers, registers the answer, unregisters what disappeared, and reports the state of all of that
57
+ * to the {@link ModelDiscoveryStatusService} so the provider's page can show it.
58
+ *
59
+ * A provider brings what only it can know: its id, its manager, the preferences it reads, how a
60
+ * discovered model becomes a model description, and the messages that name it. Everything a provider
61
+ * does beyond discovery (a custom endpoint, a proxy, its own retry settings) stays in the subclass,
62
+ * which hooks into {@link initializeProvider} and {@link handlePreferenceChange}.
63
+ */
64
+ @injectable()
65
+ export abstract class DiscoveringProviderContribution<T> implements FrontendApplicationContribution {
66
+
67
+ @inject(PreferenceService)
68
+ protected readonly preferenceService: PreferenceService;
69
+
70
+ @inject(ModelDiscoveryStatusService)
71
+ protected readonly discoveryStatus: ModelDiscoveryStatusService;
72
+
73
+ @inject(MessageService)
74
+ protected readonly messageService: MessageService;
75
+
76
+ /** Provider node id, e.g. `anthropic`. Matches the provider's node in the Models category. */
77
+ protected abstract readonly providerId: string;
78
+ /** Human-readable provider name, e.g. `Anthropic`. */
79
+ protected abstract readonly providerLabel: string;
80
+ protected abstract readonly manager: DiscoveringLanguageModelsManager<T>;
81
+ /** Preference holding an explicit model list that replaces discovery. */
82
+ protected abstract readonly modelOverridesPreference: string;
83
+ /** Preference holding the consent to use an API key found in the environment. */
84
+ protected abstract readonly allowEnvironmentApiKeyPreference: string;
85
+
86
+ /** Prefix of this provider's registered model ids. Defaults to {@link providerId}. */
87
+ protected get modelIdPrefix(): string {
88
+ return this.providerId;
89
+ }
90
+
91
+ protected abstract get discoveryMessages(): ModelDiscoveryMessages;
92
+
93
+ /** Turns a model the provider reported into the description its manager registers. */
94
+ protected abstract createModelDescription(model: DiscoveredModel): T;
95
+
96
+ /** The models registered from the last discovery, as the provider reported them. */
97
+ protected discoveredModels: DiscoveredModel[] = [];
98
+ /** Ensures the env-key consent prompt is shown at most once per session. */
99
+ protected envConsentPrompted = false;
100
+ protected discovering: Promise<void> = Promise.resolve();
101
+
102
+ onStart(): void {
103
+ this.preferenceService.ready.then(() => {
104
+ // Before any key is read: an environment key stays unused until it has been confirmed.
105
+ this.manager.setAllowEnvironmentApiKey(this.isEnvironmentApiKeyAllowed());
106
+ this.initializeProvider();
107
+
108
+ this.discoveryStatus.registerProvider({
109
+ providerId: this.providerId,
110
+ label: this.providerLabel,
111
+ modelIdPrefix: this.modelIdPrefix === this.providerId ? undefined : this.modelIdPrefix,
112
+ refresh: () => this.discoverAndRegisterModels()
113
+ });
114
+ this.discoverAndRegisterModels();
115
+
116
+ this.preferenceService.onPreferenceChanged(event => {
117
+ if (event.preferenceName === this.allowEnvironmentApiKeyPreference || event.preferenceName === this.modelOverridesPreference) {
118
+ // Withdrawing the consent has to stop the key being used, not just stop discovering.
119
+ this.manager.setAllowEnvironmentApiKey(this.isEnvironmentApiKeyAllowed());
120
+ this.discoverAndRegisterModels();
121
+ } else {
122
+ this.handlePreferenceChange(event);
123
+ }
124
+ });
125
+ });
126
+ }
127
+
128
+ /**
129
+ * Hands the provider's own configuration to its manager, before the first discovery run asks it
130
+ * anything. Also the place to register the models and listeners that discovery does not cover.
131
+ */
132
+ protected initializeProvider(): void {
133
+ }
134
+
135
+ /** Handles a change to a preference of this provider that is not part of discovery itself. */
136
+ protected handlePreferenceChange(event: PreferenceChange): void {
137
+ }
138
+
139
+ /** Fetches what the provider offers and registers it, removing whatever disappeared. */
140
+ protected async discoverAndRegisterModels(): Promise<void> {
141
+ // Runs are queued rather than overlapped: startup, a key change and a manual refresh can all
142
+ // ask within a moment of each other, and two runs in flight would compute what to unregister
143
+ // from the same stale list and leave the registry disagreeing with the provider.
144
+ this.discovering = this.discovering.then(() => this.runDiscovery().catch(error => this.discoveryStatus.reportError(this.providerId, error)));
145
+ return this.discovering;
146
+ }
147
+
148
+ protected async runDiscovery(): Promise<void> {
149
+ // A configured model list replaces discovery entirely: the provider is not asked what it
150
+ // offers, so the list is exactly what was configured. This is what pins a catalogue against
151
+ // provider changes, and the way to name models when the list endpoint cannot be reached at all.
152
+ const overrides = this.getModelOverrides();
153
+ if (overrides.length > 0) {
154
+ const configured = overrides.map(id => ({ id }));
155
+ await this.applyDiscoveredModels(configured);
156
+ this.discoveryStatus.updateStatus(this.providerId, {
157
+ state: 'overridden',
158
+ discovered: configured,
159
+ message: this.discoveryMessages.overridden,
160
+ lastFetch: undefined,
161
+ fromCache: false
162
+ });
163
+ return;
164
+ }
165
+ const source = await this.manager.getApiKeySource();
166
+ if (source === 'none') {
167
+ this.removeAllDiscoveredModels();
168
+ this.discoveryStatus.updateStatus(this.providerId, { state: 'no-credentials', message: this.discoveryMessages.noCredentials });
169
+ return;
170
+ }
171
+ // An API key from the environment is only used once the user has confirmed it.
172
+ if (source === 'environment' && !this.isEnvironmentApiKeyAllowed()) {
173
+ this.removeAllDiscoveredModels();
174
+ this.discoveryStatus.updateStatus(this.providerId, { state: 'awaiting-consent', message: this.discoveryMessages.consentRequired });
175
+ this.promptEnvironmentApiKeyConsentOnce();
176
+ return;
177
+ }
178
+ this.discoveryStatus.updateStatus(this.providerId, { state: 'fetching', message: undefined });
179
+ const { models, fromCache, error } = await this.manager.fetchAvailableModels();
180
+ await this.applyDiscoveredModels(models);
181
+ this.discoveryStatus.updateStatus(this.providerId, {
182
+ state: 'ready',
183
+ discovered: models,
184
+ // When served from cache after a failed refresh, keep the models usable but flag staleness.
185
+ message: fromCache && error ? this.discoveryMessages.cached(error) : undefined,
186
+ lastFetch: fromCache ? undefined : Date.now(),
187
+ fromCache
188
+ });
189
+ }
190
+
191
+ /** Registers the discovered models and unregisters the ones that are no longer offered. */
192
+ protected async applyDiscoveredModels(models: DiscoveredModel[]): Promise<void> {
193
+ const modelIds = models.map(model => model.id);
194
+ const removed = this.discoveredModels.filter(model => !modelIds.includes(model.id));
195
+ this.manager.removeLanguageModels(...removed.map(model => this.qualifiedModelId(model.id)));
196
+ await this.manager.createOrUpdateLanguageModels(...models.map(model => this.createModelDescription(model)));
197
+ this.discoveredModels = models;
198
+ }
199
+
200
+ /** Re-applies the descriptions of the already-discovered models, after a preference that feeds them changed. */
201
+ protected updateDiscoveredModels(): void {
202
+ this.manager.createOrUpdateLanguageModels(...this.discoveredModels.map(model => this.createModelDescription(model)));
203
+ }
204
+
205
+ protected removeAllDiscoveredModels(): void {
206
+ if (this.discoveredModels.length > 0) {
207
+ this.manager.removeLanguageModels(...this.discoveredModels.map(model => this.qualifiedModelId(model.id)));
208
+ this.discoveredModels = [];
209
+ }
210
+ }
211
+
212
+ protected qualifiedModelId(modelId: string): string {
213
+ return `${this.modelIdPrefix}/${modelId}`;
214
+ }
215
+
216
+ protected getModelOverrides(): string[] {
217
+ return this.preferenceService.get<string[]>(this.modelOverridesPreference, []);
218
+ }
219
+
220
+ protected isEnvironmentApiKeyAllowed(): boolean {
221
+ return this.preferenceService.get<boolean>(this.allowEnvironmentApiKeyPreference, false);
222
+ }
223
+
224
+ /**
225
+ * Asks the user once per session to confirm using an environment-provided API key. Confirming
226
+ * persists {@link allowEnvironmentApiKeyPreference}, which re-triggers discovery.
227
+ */
228
+ protected promptEnvironmentApiKeyConsentOnce(): void {
229
+ if (this.envConsentPrompted) {
230
+ return;
231
+ }
232
+ this.envConsentPrompted = true;
233
+ const useKey = this.discoveryMessages.useEnvironmentKey;
234
+ const ignore = nls.localizeByDefault('Ignore');
235
+ this.messageService.info(this.discoveryMessages.consentPrompt, useKey, ignore).then(choice => {
236
+ if (choice === useKey) {
237
+ this.preferenceService.set(this.allowEnvironmentApiKeyPreference, true, PreferenceScope.User);
238
+ }
239
+ });
240
+ }
241
+ }
@@ -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
+ });