@theia/ai-core 1.76.0-next.7 → 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,304 @@
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 { ApiKeySource, DiscoveredModel, ModelDiscoveryResult } from '../common/model-discovery-status';
19
+ import { DiscoveringLanguageModelsManager, DiscoveringProviderContribution, ModelDiscoveryMessages } from './discovering-provider-contribution';
20
+ import { ModelDiscoveryStatusService } from './model-discovery-status-service';
21
+ import { MockLogger } from '@theia/core/lib/common/test/mock-logger';
22
+
23
+ interface TestModelDescription {
24
+ id: string;
25
+ model: string;
26
+ }
27
+
28
+ const OVERRIDES_PREF = 'test.modelOverrides';
29
+ const ALLOW_ENV_PREF = 'test.allowEnvironmentApiKey';
30
+
31
+ class TestManager implements DiscoveringLanguageModelsManager<TestModelDescription> {
32
+
33
+ result: ModelDiscoveryResult = { models: [], fromCache: false };
34
+ /** Consumed one entry per fetch, so a test can give consecutive runs different answers. */
35
+ readonly resultQueue: ModelDiscoveryResult[] = [];
36
+ failWith: Error | undefined;
37
+ apiKeySource: ApiKeySource = 'preference';
38
+ fetchCalls = 0;
39
+ allowEnvironmentApiKey: boolean | undefined;
40
+ /** Every registration and removal in the order they happened, e.g. `remove:test/a`. */
41
+ readonly calls: string[] = [];
42
+
43
+ async fetchAvailableModels(): Promise<ModelDiscoveryResult> {
44
+ this.fetchCalls++;
45
+ if (this.failWith) {
46
+ throw this.failWith;
47
+ }
48
+ return this.resultQueue.shift() ?? this.result;
49
+ }
50
+
51
+ async getApiKeySource(): Promise<ApiKeySource> {
52
+ return this.apiKeySource;
53
+ }
54
+
55
+ setAllowEnvironmentApiKey(allowed: boolean): void {
56
+ this.allowEnvironmentApiKey = allowed;
57
+ }
58
+
59
+ async createOrUpdateLanguageModels(...modelDescriptions: TestModelDescription[]): Promise<void> {
60
+ this.calls.push(`register:${modelDescriptions.map(description => description.id).join(',')}`);
61
+ }
62
+
63
+ removeLanguageModels(...modelIds: string[]): void {
64
+ this.calls.push(`remove:${modelIds.join(',')}`);
65
+ }
66
+ }
67
+
68
+ class TestContribution extends DiscoveringProviderContribution<TestModelDescription> {
69
+
70
+ protected readonly providerId = 'test';
71
+ protected readonly providerLabel = 'Test Provider';
72
+ protected readonly modelOverridesPreference = OVERRIDES_PREF;
73
+ protected readonly allowEnvironmentApiKeyPreference = ALLOW_ENV_PREF;
74
+ protected readonly manager: TestManager;
75
+
76
+ initialized = 0;
77
+
78
+ constructor(manager: TestManager) {
79
+ super();
80
+ this.manager = manager;
81
+ }
82
+
83
+ protected get discoveryMessages(): ModelDiscoveryMessages {
84
+ return {
85
+ noCredentials: 'no key',
86
+ consentRequired: 'confirm the key',
87
+ consentPrompt: 'may I use the key?',
88
+ useEnvironmentKey: 'Use key',
89
+ overridden: 'configured by hand',
90
+ cached: error => `cached: ${error}`
91
+ };
92
+ }
93
+
94
+ protected createModelDescription(model: DiscoveredModel): TestModelDescription {
95
+ return { id: this.qualifiedModelId(model.id), model: model.id };
96
+ }
97
+
98
+ protected override initializeProvider(): void {
99
+ this.initialized++;
100
+ }
101
+
102
+ /** The queued entry point, which is protected on the contribution itself. */
103
+ discover(): Promise<void> {
104
+ return this.discoverAndRegisterModels();
105
+ }
106
+ }
107
+
108
+ class PrefixedContribution extends TestContribution {
109
+ protected override get modelIdPrefix(): string {
110
+ return 'prefixed';
111
+ }
112
+ }
113
+
114
+ function createContribution(
115
+ preferences: Record<string, unknown> = {},
116
+ factory: (testManager: TestManager) => TestContribution = testManager => new TestContribution(testManager)
117
+ ): {
118
+ contribution: TestContribution;
119
+ manager: TestManager;
120
+ status: ModelDiscoveryStatusService;
121
+ written: Record<string, unknown>;
122
+ prompts: string[];
123
+ answer: (choice: string | undefined) => void;
124
+ } {
125
+ const manager = new TestManager();
126
+ const contribution = factory(manager);
127
+ const status = Object.assign(new ModelDiscoveryStatusService(), { logger: new MockLogger() });
128
+ const written: Record<string, unknown> = {};
129
+ const prompts: string[] = [];
130
+ let choice: string | undefined;
131
+ const preferenceService = {
132
+ ready: Promise.resolve(),
133
+ get: (name: string, defaultValue: unknown) => (name in preferences ? preferences[name] : defaultValue),
134
+ set: async (name: string, value: unknown) => { written[name] = value; },
135
+ onPreferenceChanged: () => ({ dispose: () => { } })
136
+ };
137
+ const messageService = {
138
+ info: async (text: string) => {
139
+ prompts.push(text);
140
+ return choice;
141
+ }
142
+ };
143
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
144
+ const internals = contribution as any;
145
+ internals.preferenceService = preferenceService;
146
+ internals.discoveryStatus = status;
147
+ internals.messageService = messageService;
148
+ return { contribution, manager, status, written, prompts, answer: next => { choice = next; } };
149
+ }
150
+
151
+ function discovered(id: string): DiscoveredModel {
152
+ return { id };
153
+ }
154
+
155
+ describe('DiscoveringProviderContribution', () => {
156
+
157
+ it('registers what the provider offers and reports it as ready', async () => {
158
+ const { contribution, manager, status } = createContribution();
159
+ manager.result = { models: [discovered('a'), discovered('b')], fromCache: false };
160
+
161
+ await contribution.discover();
162
+
163
+ expect(manager.calls).to.deep.equal(['remove:', 'register:test/a,test/b']);
164
+ const reported = status.getStatus('test');
165
+ expect(reported?.state).to.equal('ready');
166
+ expect(reported?.discovered?.map(entry => entry.id)).to.deep.equal(['a', 'b']);
167
+ expect(reported?.lastFetch).to.be.a('number');
168
+ });
169
+
170
+ it('unregisters the models a later discovery no longer offers', async () => {
171
+ const { contribution, manager } = createContribution();
172
+ manager.result = { models: [discovered('a'), discovered('b')], fromCache: false };
173
+ await contribution.discover();
174
+ manager.calls.length = 0;
175
+
176
+ manager.result = { models: [discovered('b')], fromCache: false };
177
+ await contribution.discover();
178
+
179
+ expect(manager.calls).to.deep.equal(['remove:test/a', 'register:test/b']);
180
+ });
181
+
182
+ it('qualifies the model ids with the prefix the provider registers under', async () => {
183
+ const { contribution, manager } = createContribution({}, testManager => new PrefixedContribution(testManager));
184
+ manager.result = { models: [discovered('a')], fromCache: false };
185
+ await contribution.discover();
186
+ manager.calls.length = 0;
187
+
188
+ manager.result = { models: [], fromCache: false };
189
+ await contribution.discover();
190
+
191
+ expect(manager.calls).to.deep.equal(['remove:prefixed/a', 'register:']);
192
+ });
193
+
194
+ it('replaces discovery with a configured list without asking the provider', async () => {
195
+ const { contribution, manager, status } = createContribution({ [OVERRIDES_PREF]: ['pinned-1', 'pinned-2'] });
196
+
197
+ await contribution.discover();
198
+
199
+ expect(manager.fetchCalls).to.equal(0);
200
+ expect(manager.calls).to.deep.equal(['remove:', 'register:test/pinned-1,test/pinned-2']);
201
+ expect(status.getStatus('test')?.state).to.equal('overridden');
202
+ });
203
+
204
+ it('drops the registered models and asks for a credential when there is none', async () => {
205
+ const { contribution, manager, status } = createContribution();
206
+ manager.result = { models: [discovered('a')], fromCache: false };
207
+ await contribution.discover();
208
+ manager.calls.length = 0;
209
+
210
+ manager.apiKeySource = 'none';
211
+ await contribution.discover();
212
+
213
+ expect(manager.calls).to.deep.equal(['remove:test/a']);
214
+ expect(manager.fetchCalls).to.equal(1);
215
+ expect(status.getStatus('test')?.state).to.equal('no-credentials');
216
+ expect(status.getStatus('test')?.message).to.equal('no key');
217
+ });
218
+
219
+ it('withholds an environment key until it is confirmed, asking once', async () => {
220
+ const { contribution, manager, status, prompts } = createContribution();
221
+ manager.apiKeySource = 'environment';
222
+
223
+ await contribution.discover();
224
+ await contribution.discover();
225
+
226
+ expect(manager.fetchCalls).to.equal(0);
227
+ expect(status.getStatus('test')?.state).to.equal('awaiting-consent');
228
+ // Asked once per session, however often the state is reached again.
229
+ expect(prompts).to.deep.equal(['may I use the key?']);
230
+ });
231
+
232
+ it('persists the consent when the user grants it', async () => {
233
+ const { contribution, manager, written, answer } = createContribution();
234
+ manager.apiKeySource = 'environment';
235
+ answer('Use key');
236
+
237
+ await contribution.discover();
238
+ // The prompt is answered asynchronously, after the run itself has returned.
239
+ await Promise.resolve();
240
+
241
+ expect(written[ALLOW_ENV_PREF]).to.equal(true);
242
+ });
243
+
244
+ it('uses an environment key once the consent is on record', async () => {
245
+ const { contribution, manager, status } = createContribution({ [ALLOW_ENV_PREF]: true });
246
+ manager.apiKeySource = 'environment';
247
+ manager.result = { models: [discovered('a')], fromCache: false };
248
+
249
+ await contribution.discover();
250
+
251
+ expect(manager.fetchCalls).to.equal(1);
252
+ expect(status.getStatus('test')?.state).to.equal('ready');
253
+ });
254
+
255
+ it('keeps cached models usable and says that they may be stale', async () => {
256
+ const { contribution, status } = createContribution();
257
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
258
+ (contribution as any).manager.result = { models: [discovered('a')], fromCache: true, error: 'network down' };
259
+
260
+ await contribution.discover();
261
+
262
+ const reported = status.getStatus('test');
263
+ expect(reported?.state).to.equal('ready');
264
+ expect(reported?.fromCache).to.equal(true);
265
+ expect(reported?.message).to.equal('cached: network down');
266
+ // Nothing was fetched, so there is no successful fetch to date.
267
+ expect(reported?.lastFetch).to.equal(undefined);
268
+ });
269
+
270
+ it('reports a failed run instead of rejecting, since nobody awaits it', async () => {
271
+ const { contribution, manager, status } = createContribution();
272
+ manager.failWith = new Error('the endpoint is gone');
273
+
274
+ // Rejecting here would surface as an unhandled rejection from the unawaited startup call.
275
+ await contribution.discover();
276
+
277
+ expect(status.getStatus('test')?.state).to.equal('error');
278
+ expect(status.getStatus('test')?.message).to.equal('the endpoint is gone');
279
+ });
280
+
281
+ it('queues overlapping runs so they cannot compute what to unregister from the same list', async () => {
282
+ const { contribution, manager } = createContribution();
283
+ // The runs start before either has fetched, so what they see has to come from the queue.
284
+ manager.resultQueue.push({ models: [discovered('a')], fromCache: false }, { models: [discovered('b')], fromCache: false });
285
+
286
+ await Promise.all([contribution.discover(), contribution.discover()]);
287
+
288
+ // The second run sees what the first registered, rather than the empty list both started from.
289
+ expect(manager.calls).to.deep.equal(['remove:', 'register:test/a', 'remove:test/a', 'register:test/b']);
290
+ });
291
+
292
+ it('registers the provider, hands over the consent and discovers on startup', async () => {
293
+ const { contribution, manager, status } = createContribution();
294
+ manager.result = { models: [discovered('a')], fromCache: false };
295
+
296
+ contribution.onStart();
297
+ await contribution.discover();
298
+
299
+ expect(status.getStatus('test')?.label).to.equal('Test Provider');
300
+ expect(manager.allowEnvironmentApiKey).to.equal(false);
301
+ expect(contribution.initialized).to.equal(1);
302
+ expect(manager.fetchCalls).to.be.greaterThan(0);
303
+ });
304
+ });
@@ -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
+ }