@theia/ai-core 1.76.0-next.23 → 1.76.0-next.33

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 (111) 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 +12 -0
  49. package/lib/common/language-model.d.ts.map +1 -1
  50. package/lib/common/language-model.js +16 -0
  51. package/lib/common/language-model.js.map +1 -1
  52. package/lib/common/language-model.spec.js +13 -0
  53. package/lib/common/language-model.spec.js.map +1 -1
  54. package/lib/common/model-discovery-status.d.ts +112 -0
  55. package/lib/common/model-discovery-status.d.ts.map +1 -0
  56. package/lib/common/model-discovery-status.js +18 -0
  57. package/lib/common/model-discovery-status.js.map +1 -0
  58. package/lib/common/model-discovery-util.d.ts +46 -0
  59. package/lib/common/model-discovery-util.d.ts.map +1 -0
  60. package/lib/common/model-discovery-util.js +128 -0
  61. package/lib/common/model-discovery-util.js.map +1 -0
  62. package/lib/common/model-discovery-util.spec.d.ts +2 -0
  63. package/lib/common/model-discovery-util.spec.d.ts.map +1 -0
  64. package/lib/common/model-discovery-util.spec.js +95 -0
  65. package/lib/common/model-discovery-util.spec.js.map +1 -0
  66. package/lib/node/ai-core-backend-module.d.ts.map +1 -1
  67. package/lib/node/ai-core-backend-module.js +8 -0
  68. package/lib/node/ai-core-backend-module.js.map +1 -1
  69. package/lib/node/backend-language-model-registry.d.ts.map +1 -1
  70. package/lib/node/backend-language-model-registry.js +1 -0
  71. package/lib/node/backend-language-model-registry.js.map +1 -1
  72. package/lib/node/index.d.ts +2 -0
  73. package/lib/node/index.d.ts.map +1 -1
  74. package/lib/node/index.js +2 -0
  75. package/lib/node/index.js.map +1 -1
  76. package/lib/node/model-discovery-fetcher.d.ts +43 -0
  77. package/lib/node/model-discovery-fetcher.d.ts.map +1 -0
  78. package/lib/node/model-discovery-fetcher.js +83 -0
  79. package/lib/node/model-discovery-fetcher.js.map +1 -0
  80. package/lib/node/model-snapshot-store.d.ts +26 -0
  81. package/lib/node/model-snapshot-store.d.ts.map +1 -0
  82. package/lib/node/model-snapshot-store.js +73 -0
  83. package/lib/node/model-snapshot-store.js.map +1 -0
  84. package/lib/node/test/test-model-discovery-fetcher.d.ts +21 -0
  85. package/lib/node/test/test-model-discovery-fetcher.d.ts.map +1 -0
  86. package/lib/node/test/test-model-discovery-fetcher.js +54 -0
  87. package/lib/node/test/test-model-discovery-fetcher.js.map +1 -0
  88. package/package.json +11 -11
  89. package/src/browser/ai-core-frontend-module.ts +5 -0
  90. package/src/browser/discovering-provider-contribution.spec.ts +303 -0
  91. package/src/browser/discovering-provider-contribution.ts +241 -0
  92. package/src/browser/favorite-models-service.spec.ts +336 -0
  93. package/src/browser/favorite-models-service.ts +297 -0
  94. package/src/browser/index.ts +3 -0
  95. package/src/browser/model-discovery-status-service.spec.ts +95 -0
  96. package/src/browser/model-discovery-status-service.ts +118 -0
  97. package/src/common/ai-core-preferences.ts +40 -0
  98. package/src/common/index.ts +2 -0
  99. package/src/common/language-model-ordering.spec.ts +83 -0
  100. package/src/common/language-model-util.ts +43 -0
  101. package/src/common/language-model.spec.ts +14 -0
  102. package/src/common/language-model.ts +22 -0
  103. package/src/common/model-discovery-status.ts +132 -0
  104. package/src/common/model-discovery-util.spec.ts +105 -0
  105. package/src/common/model-discovery-util.ts +129 -0
  106. package/src/node/ai-core-backend-module.ts +8 -0
  107. package/src/node/backend-language-model-registry.ts +1 -0
  108. package/src/node/index.ts +2 -0
  109. package/src/node/model-discovery-fetcher.ts +103 -0
  110. package/src/node/model-snapshot-store.ts +80 -0
  111. package/src/node/test/test-model-discovery-fetcher.ts +62 -0
@@ -0,0 +1,95 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 EclipseSource GmbH.
3
+ //
4
+ // This program and the accompanying materials are made available under the
5
+ // terms of the Eclipse Public License v. 2.0 which is available at
6
+ // http://www.eclipse.org/legal/epl-2.0.
7
+ //
8
+ // This Source Code may also be made available under the following Secondary
9
+ // Licenses when the conditions for such availability set forth in the Eclipse
10
+ // Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
+ // with the GNU Classpath Exception which is available at
12
+ // https://www.gnu.org/software/classpath/license.html.
13
+ //
14
+ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
+ // *****************************************************************************
16
+
17
+ import { expect } from 'chai';
18
+ import { ModelDiscoveryStatus } from '../common/model-discovery-status';
19
+ import { ModelDiscoveryStatusService } from './model-discovery-status-service';
20
+
21
+ describe('ModelDiscoveryStatusService', () => {
22
+
23
+ it('registers a provider with an initial idle status', () => {
24
+ const service = new ModelDiscoveryStatusService();
25
+ service.registerProvider({ providerId: 'anthropic', label: 'Anthropic', refresh: async () => { } });
26
+ const status = service.getStatus('anthropic');
27
+ expect(status?.state).to.equal('idle');
28
+ expect(status?.label).to.equal('Anthropic');
29
+ });
30
+
31
+ it('merges partial updates and fires onDidChange', () => {
32
+ const service = new ModelDiscoveryStatusService();
33
+ service.registerProvider({ providerId: 'anthropic', label: 'Anthropic', refresh: async () => { } });
34
+
35
+ const events: ModelDiscoveryStatus[] = [];
36
+ service.onDidChange(e => events.push(e));
37
+
38
+ service.updateStatus('anthropic', { state: 'fetching' });
39
+ service.updateStatus('anthropic', { state: 'ready', lastFetch: 123, fromCache: false });
40
+
41
+ const status = service.getStatus('anthropic');
42
+ expect(status?.state).to.equal('ready');
43
+ expect(status?.lastFetch).to.equal(123);
44
+ // label is carried over from registration.
45
+ expect(status?.label).to.equal('Anthropic');
46
+ expect(events.map(e => e.state)).to.deep.equal(['fetching', 'ready']);
47
+ });
48
+
49
+ it('does not reset an existing status when the provider is re-registered', () => {
50
+ const service = new ModelDiscoveryStatusService();
51
+ service.registerProvider({ providerId: 'anthropic', label: 'Anthropic', refresh: async () => { } });
52
+ service.updateStatus('anthropic', { state: 'ready' });
53
+
54
+ service.registerProvider({ providerId: 'anthropic', label: 'Anthropic', refresh: async () => { } });
55
+ expect(service.getStatus('anthropic')?.state).to.equal('ready');
56
+ });
57
+
58
+ it('reports a failure and clears what the previous state offered', () => {
59
+ const service = new ModelDiscoveryStatusService();
60
+ service.registerProvider({ providerId: 'copilot', label: 'GitHub Copilot', refresh: async () => { } });
61
+ service.updateStatus('copilot', {
62
+ state: 'no-credentials',
63
+ stateLabel: 'Not signed in',
64
+ action: { label: 'Sign in', commandId: 'copilot.signIn' }
65
+ });
66
+
67
+ service.reportError('copilot', new Error('the CLI is not talking'));
68
+
69
+ const status = service.getStatus('copilot');
70
+ expect(status?.state).to.equal('error');
71
+ expect(status?.message).to.equal('the CLI is not talking');
72
+ // A failure must not keep reading like the state before it.
73
+ expect(status?.stateLabel).to.equal(undefined);
74
+ expect(status?.action).to.equal(undefined);
75
+ });
76
+
77
+ it('reports a rejection that is not an error', () => {
78
+ const service = new ModelDiscoveryStatusService();
79
+ service.registerProvider({ providerId: 'anthropic', label: 'Anthropic', refresh: async () => { } });
80
+ service.reportError('anthropic', 'nope');
81
+ expect(service.getStatus('anthropic')?.message).to.equal('nope');
82
+ });
83
+
84
+ it('delegates refresh to the registered provider', async () => {
85
+ const service = new ModelDiscoveryStatusService();
86
+ let refreshed = false;
87
+ service.registerProvider({ providerId: 'anthropic', label: 'Anthropic', refresh: async () => { refreshed = true; } });
88
+
89
+ await service.refresh('anthropic');
90
+ expect(refreshed).to.be.true;
91
+
92
+ // Refreshing an unknown provider is a no-op and does not throw.
93
+ await service.refresh('unknown');
94
+ });
95
+ });
@@ -0,0 +1,118 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 EclipseSource GmbH.
3
+ //
4
+ // This program and the accompanying materials are made available under the
5
+ // terms of the Eclipse Public License v. 2.0 which is available at
6
+ // http://www.eclipse.org/legal/epl-2.0.
7
+ //
8
+ // This Source Code may also be made available under the following Secondary
9
+ // Licenses when the conditions for such availability set forth in the Eclipse
10
+ // Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
+ // with the GNU Classpath Exception which is available at
12
+ // https://www.gnu.org/software/classpath/license.html.
13
+ //
14
+ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
+ // *****************************************************************************
16
+
17
+ import { Emitter, Event } from '@theia/core';
18
+ import { injectable } from '@theia/core/shared/inversify';
19
+ import { ModelDiscoveryStatus } from '../common/model-discovery-status';
20
+
21
+ /**
22
+ * A provider that participates in dynamic model discovery. Provider frontend contributions
23
+ * register a descriptor so the UI can display their {@link ModelDiscoveryStatus} and trigger
24
+ * a manual refresh.
25
+ */
26
+ export interface ModelDiscoveryProviderDescriptor {
27
+ readonly providerId: string;
28
+ readonly label: string;
29
+ /** Prefix of this provider's registered model ids, if it differs from {@link providerId}. */
30
+ readonly modelIdPrefix?: string;
31
+ /** Triggers a manual re-fetch (used by the "Refresh"/"Retry" action). */
32
+ refresh(): Promise<void>;
33
+ }
34
+
35
+ /**
36
+ * Frontend registry of provider-level model-discovery status.
37
+ *
38
+ * Provider frontend contributions push their state here as they fetch models; the AI
39
+ * configuration UI observes {@link onDidChange} to render fetching/error/no-credentials state.
40
+ * This is the provider-level counterpart to the per-model `LanguageModelStatus`.
41
+ */
42
+ @injectable()
43
+ export class ModelDiscoveryStatusService {
44
+
45
+ protected readonly descriptors = new Map<string, ModelDiscoveryProviderDescriptor>();
46
+ protected readonly statuses = new Map<string, ModelDiscoveryStatus>();
47
+
48
+ protected readonly onDidChangeEmitter = new Emitter<ModelDiscoveryStatus>();
49
+ /** Fired whenever a provider's status changes; the argument is the new status. */
50
+ readonly onDidChange: Event<ModelDiscoveryStatus> = this.onDidChangeEmitter.event;
51
+
52
+ /**
53
+ * Register a provider. Idempotent: re-registering keeps any existing status and only
54
+ * updates the refresh callback and label.
55
+ */
56
+ registerProvider(descriptor: ModelDiscoveryProviderDescriptor): void {
57
+ this.descriptors.set(descriptor.providerId, descriptor);
58
+ if (!this.statuses.has(descriptor.providerId)) {
59
+ this.statuses.set(descriptor.providerId, {
60
+ providerId: descriptor.providerId,
61
+ label: descriptor.label,
62
+ modelIdPrefix: descriptor.modelIdPrefix,
63
+ state: 'idle'
64
+ });
65
+ }
66
+ this.onDidChangeEmitter.fire(this.statuses.get(descriptor.providerId)!);
67
+ }
68
+
69
+ /**
70
+ * Update a provider's status. Only the provided fields are changed; the rest are carried
71
+ * over from the previous status. Passing `undefined` for an optional field explicitly clears it.
72
+ */
73
+ updateStatus(providerId: string, update: Partial<Omit<ModelDiscoveryStatus, 'providerId'>>): void {
74
+ const previous = this.statuses.get(providerId);
75
+ const next: ModelDiscoveryStatus = {
76
+ providerId,
77
+ label: update.label ?? previous?.label ?? providerId,
78
+ modelIdPrefix: 'modelIdPrefix' in update ? update.modelIdPrefix : previous?.modelIdPrefix,
79
+ state: update.state ?? previous?.state ?? 'idle',
80
+ stateLabel: 'stateLabel' in update ? update.stateLabel : previous?.stateLabel,
81
+ message: 'message' in update ? update.message : previous?.message,
82
+ lastFetch: 'lastFetch' in update ? update.lastFetch : previous?.lastFetch,
83
+ fromCache: 'fromCache' in update ? update.fromCache : previous?.fromCache,
84
+ discovered: 'discovered' in update ? update.discovered : previous?.discovered,
85
+ action: 'action' in update ? update.action : previous?.action
86
+ };
87
+ this.statuses.set(providerId, next);
88
+ this.onDidChangeEmitter.fire(next);
89
+ }
90
+
91
+ /**
92
+ * Reports a failed discovery run. Any step of a run can fail, including the ones that only talk to
93
+ * the backend, and nothing awaits the promise a rejection would travel on: a provider's startup
94
+ * does not, and a refresh button cannot act on it. What the previous state offered is cleared, so
95
+ * a failure does not keep reading like the state before it (a badge, a sign-in button).
96
+ */
97
+ reportError(providerId: string, error: unknown): void {
98
+ this.updateStatus(providerId, {
99
+ state: 'error',
100
+ stateLabel: undefined,
101
+ message: error instanceof Error ? error.message : String(error),
102
+ action: undefined
103
+ });
104
+ }
105
+
106
+ getStatus(providerId: string): ModelDiscoveryStatus | undefined {
107
+ return this.statuses.get(providerId);
108
+ }
109
+
110
+ getStatuses(): ModelDiscoveryStatus[] {
111
+ return [...this.statuses.values()];
112
+ }
113
+
114
+ /** Trigger a manual refresh for the given provider, if it is registered. */
115
+ async refresh(providerId: string): Promise<void> {
116
+ await this.descriptors.get(providerId)?.refresh();
117
+ }
118
+ }
@@ -38,6 +38,15 @@ export const AI_CORE_PREFERENCES_TITLE = nls.localize('theia/ai-core/preferences
38
38
  */
39
39
  export const MODEL_PROVIDER_TYPE_DETAIL = 'aiModelProvider';
40
40
 
41
+ /**
42
+ * Key under a preference's schema `typeDetails` marking a preference that the AI Configuration view
43
+ * edits through a dedicated control of its own, so it should not also appear as a raw settings row.
44
+ * The value stays a plain preference, editable in `settings.json` like any other; it is only the
45
+ * duplicate, less legible editor that is left out. Declare it as
46
+ * `typeDetails: { [DEDICATED_EDITOR_TYPE_DETAIL]: true }`.
47
+ */
48
+ export const DEDICATED_EDITOR_TYPE_DETAIL = 'aiDedicatedEditor';
49
+
41
50
  /**
42
51
  * Shape a language-model provider stores under a preference's `typeDetails[MODEL_PROVIDER_TYPE_DETAIL]`
43
52
  * to describe its `ai-features.<provider>.*` block to the AI Configuration view's Models page. Declared
@@ -51,6 +60,8 @@ export const PREFERENCE_NAME_PROMPT_TEMPLATES = 'ai-features.promptTemplates.pro
51
60
  export const PREFERENCE_NAME_REQUEST_SETTINGS = 'ai-features.modelSettings.requestSettings';
52
61
  export const PREFERENCE_NAME_REASONING = 'ai-features.reasoning.defaults';
53
62
  export const PREFERENCE_NAME_MAX_RETRIES = 'ai-features.modelSettings.maxRetries';
63
+ export const PREFERENCE_NAME_FAVORITE_MODELS = 'ai-features.modelSettings.favoriteModels';
64
+ export const PREFERENCE_NAME_HIDDEN_MODELS = 'ai-features.modelSettings.hiddenModels';
54
65
  export const PREFERENCE_NAME_DEFAULT_NOTIFICATION_TYPE = 'ai-features.notifications.default';
55
66
  export const PREFERENCE_NAME_SKILL_DIRECTORIES = 'ai-features.skills.skillDirectories';
56
67
  export const PREFERENCE_NAME_SERVER_SIDE_COMPACTION = 'ai-features.chat.serverSideCompaction';
@@ -169,6 +180,34 @@ export const aiCorePreferenceSchema: PreferenceSchema = {
169
180
  minimum: 0,
170
181
  default: 3
171
182
  },
183
+ [PREFERENCE_NAME_FAVORITE_MODELS]: {
184
+ title: nls.localize('theia/ai/core/favoriteModels/title', 'Favorite Models'),
185
+ markdownDescription: nls.localize('theia/ai/core/favoriteModels/mdDescription',
186
+ 'Models the AI chat input\'s model picker shows in addition to the newest ones of each provider, as fully qualified ids '
187
+ + '(e.g. `anthropic/claude-opus-5`). This is for keeping an older or release-pinned model at hand. Check a model on its '
188
+ + 'provider\'s page in the AI Configuration view to add it.'),
189
+ type: 'array',
190
+ // The provider pages check models one by one; a second, raw editor for the same ids would
191
+ // invite typos and disagree with what the checks say.
192
+ typeDetails: { [DEDICATED_EDITOR_TYPE_DETAIL]: true },
193
+ items: {
194
+ type: 'string'
195
+ },
196
+ default: []
197
+ },
198
+ [PREFERENCE_NAME_HIDDEN_MODELS]: {
199
+ title: nls.localizeByDefault('Hidden Models'),
200
+ markdownDescription: nls.localize('theia/ai/core/hiddenModels/mdDescription',
201
+ 'Models the AI chat input\'s model picker does not show even though it would by default, as fully qualified ids '
202
+ + '(e.g. `google/gemini-flash-lite-latest`). Only the models shown without being listed as a favorite end up here, '
203
+ + 'i.e. the newest ones of a provider. Uncheck a model on its provider\'s page in the AI Configuration view to add it.'),
204
+ type: 'array',
205
+ typeDetails: { [DEDICATED_EDITOR_TYPE_DETAIL]: true },
206
+ items: {
207
+ type: 'string'
208
+ },
209
+ default: []
210
+ },
172
211
  [PREFERENCE_NAME_DEFAULT_NOTIFICATION_TYPE]: {
173
212
  title: nls.localize('theia/ai/core/defaultNotification/title', 'Default Notification Type'),
174
213
  markdownDescription: nls.localize('theia/ai/core/defaultNotification/mdDescription',
@@ -276,6 +315,7 @@ export interface AICoreConfiguration {
276
315
  [PREFERENCE_NAME_REQUEST_SETTINGS]: Array<RequestSetting> | undefined;
277
316
  [PREFERENCE_NAME_REASONING]: Array<ReasoningPreferenceEntry> | undefined;
278
317
  [PREFERENCE_NAME_MAX_RETRIES]: number | undefined;
318
+ [PREFERENCE_NAME_FAVORITE_MODELS]: string[] | undefined;
279
319
  [PREFERENCE_NAME_DEFAULT_NOTIFICATION_TYPE]: NotificationType | undefined;
280
320
  [PREFERENCE_NAME_SKILL_DIRECTORIES]: string[] | undefined;
281
321
  [PREFERENCE_NAME_SERVER_SIDE_COMPACTION]: boolean | undefined;
@@ -22,6 +22,8 @@ export * from './language-model-delegate';
22
22
  export * from './language-model-util';
23
23
  export * from './language-model';
24
24
  export * from './language-model-alias';
25
+ export * from './model-discovery-status';
26
+ export * from './model-discovery-util';
25
27
  export * from './prompt-service';
26
28
  export * from './prompt-service-util';
27
29
  export * from './proxy-util';
@@ -0,0 +1,83 @@
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 { LanguageModelMetaData } from './language-model';
19
+ import { compareModelsByRecency, groupModelsByProvider, providerOf } from './language-model-util';
20
+
21
+ function model(id: string, released?: number, vendor?: string): LanguageModelMetaData {
22
+ return { id, released, vendor, status: { status: 'ready' } };
23
+ }
24
+
25
+ describe('compareModelsByRecency', () => {
26
+
27
+ it('puts the most recently released model first', () => {
28
+ const sorted = [
29
+ model('anthropic/claude-haiku-4-5', Date.parse('2025-10-01')),
30
+ model('anthropic/claude-opus-5', Date.parse('2026-04-01'))
31
+ ].sort(compareModelsByRecency);
32
+ expect(sorted.map(entry => entry.id)).to.deep.equal(['anthropic/claude-opus-5', 'anthropic/claude-haiku-4-5']);
33
+ });
34
+
35
+ it('falls back to the id when no release date is reported, as for Gemini', () => {
36
+ const sorted = [model('google/gemini-3.7-flash'), model('google/gemini-3.1-pro')].sort(compareModelsByRecency);
37
+ expect(sorted.map(entry => entry.id)).to.deep.equal(['google/gemini-3.1-pro', 'google/gemini-3.7-flash']);
38
+ });
39
+
40
+ it('sorts a model without a reported date after the dated ones rather than to the top', () => {
41
+ const sorted = [
42
+ model('openai/undated'),
43
+ model('openai/dated', Date.parse('2020-01-01'))
44
+ ].sort(compareModelsByRecency);
45
+ expect(sorted.map(entry => entry.id)).to.deep.equal(['openai/dated', 'openai/undated']);
46
+ });
47
+ });
48
+
49
+ describe('providerOf', () => {
50
+
51
+ it('prefers the declared vendor', () => {
52
+ expect(providerOf(model('openai/gpt-5.5', undefined, 'openai'))).to.equal('openai');
53
+ });
54
+
55
+ it('falls back to the id prefix', () => {
56
+ expect(providerOf(model('anthropic/claude-opus-5'))).to.equal('anthropic');
57
+ });
58
+
59
+ it('yields an empty provider for an unprefixed id, e.g. a custom endpoint', () => {
60
+ expect(providerOf(model('my-local-model'))).to.equal('');
61
+ });
62
+ });
63
+
64
+ describe('groupModelsByProvider', () => {
65
+
66
+ it('groups by provider, ordering the groups by name and their models by recency', () => {
67
+ const groups = groupModelsByProvider([
68
+ model('openai/gpt-5.5', Date.parse('2025-08-01')),
69
+ model('anthropic/claude-haiku-4-5', Date.parse('2025-10-01')),
70
+ model('openai/gpt-5.6-sol', Date.parse('2026-02-01')),
71
+ model('anthropic/claude-opus-5', Date.parse('2026-04-01'))
72
+ ]);
73
+ expect(groups.map(group => group.provider)).to.deep.equal(['anthropic', 'openai']);
74
+ expect(groups[0].models.map(entry => entry.id)).to.deep.equal(['anthropic/claude-opus-5', 'anthropic/claude-haiku-4-5']);
75
+ expect(groups[1].models.map(entry => entry.id)).to.deep.equal(['openai/gpt-5.6-sol', 'openai/gpt-5.5']);
76
+ });
77
+
78
+ it('leaves the input untouched', () => {
79
+ const models = [model('b/second'), model('a/first')];
80
+ groupModelsByProvider(models);
81
+ expect(models.map(entry => entry.id)).to.deep.equal(['b/second', 'a/first']);
82
+ });
83
+ });
@@ -19,6 +19,7 @@ import {
19
19
  isLanguageModelStreamResponse,
20
20
  isLanguageModelTextResponse,
21
21
  isTextResponsePart,
22
+ LanguageModelMetaData,
22
23
  LanguageModelResponse,
23
24
  ToolRequest
24
25
  } from './language-model';
@@ -80,3 +81,45 @@ export const getJsonOfText = (text: string): unknown => {
80
81
  };
81
82
 
82
83
  export const toolRequestToPromptText = (toolRequest: ToolRequest): string => `${toolRequest.id}`;
84
+
85
+ /**
86
+ * Orders models for the lists a user picks from: newest first, since a provider's newest model is
87
+ * almost always the one being looked for, with the id as the tie-break so that models whose provider
88
+ * reports no release date (Gemini reports none) still come out in a stable, readable order.
89
+ */
90
+ export const compareModelsByRecency = (left: LanguageModelMetaData, right: LanguageModelMetaData): number => {
91
+ if (left.released !== right.released) {
92
+ // A model without a reported date sorts after the dated ones rather than to the top.
93
+ return (right.released ?? 0) - (left.released ?? 0);
94
+ }
95
+ return left.id.localeCompare(right.id);
96
+ };
97
+
98
+ /**
99
+ * The provider a model belongs to: its declared vendor, falling back to the `<provider>/` prefix of
100
+ * its id. Used to group the model lists so that a provider's models stay together.
101
+ */
102
+ export const providerOf = (model: LanguageModelMetaData): string => {
103
+ if (model.vendor) {
104
+ return model.vendor;
105
+ }
106
+ const separator = model.id.indexOf('/');
107
+ return separator > 0 ? model.id.substring(0, separator) : '';
108
+ };
109
+
110
+ /** Groups models by {@link providerOf}, each group ordered by {@link compareModelsByRecency}, groups by provider name. */
111
+ export const groupModelsByProvider = <T extends LanguageModelMetaData>(models: T[]): Array<{ provider: string; models: T[] }> => {
112
+ const groups = new Map<string, T[]>();
113
+ for (const model of models) {
114
+ const provider = providerOf(model);
115
+ const group = groups.get(provider);
116
+ if (group) {
117
+ group.push(model);
118
+ } else {
119
+ groups.set(provider, [model]);
120
+ }
121
+ }
122
+ return [...groups.entries()]
123
+ .sort(([left], [right]) => left.localeCompare(right))
124
+ .map(([provider, grouped]) => ({ provider, models: grouped.slice().sort(compareModelsByRecency) }));
125
+ };
@@ -27,6 +27,7 @@ import {
27
27
  resolveCompactionDefault,
28
28
  resolveCompactionTokenThreshold,
29
29
  resolveCompactionTokenThresholdDefault,
30
+ resolveCustomModelHeaders,
30
31
  resolveServerSideCompaction
31
32
  } from './language-model';
32
33
 
@@ -260,6 +261,19 @@ describe('compaction contract', () => {
260
261
  expect(resolveServerSideCompaction(true, false, { enabled: true })).to.equal(true);
261
262
  expect(resolveServerSideCompaction(true, true, { enabled: false })).to.equal(false);
262
263
  });
264
+ it('resolves custom model headers, ignoring non-string and non-object values', () => {
265
+ expect(resolveCustomModelHeaders({ 'X-Audit-User': 'theia' })).to.deep.equal({ 'X-Audit-User': 'theia' });
266
+ // keys are sorted, so reordering the entries in the preference is not a change
267
+ expect(Object.keys(resolveCustomModelHeaders({ 'X-B': '2', 'X-A': '1' })!)).to.deep.equal(['X-A', 'X-B']);
268
+ // entries with a non-string value are dropped, the remaining ones are kept
269
+ expect(resolveCustomModelHeaders({ 'X-Audit-User': 'theia', 'X-Count': 5 })).to.deep.equal({ 'X-Audit-User': 'theia' });
270
+ // nothing usable -> undefined, so the default request headers stay untouched
271
+ expect(resolveCustomModelHeaders({ 'X-Count': 5 })).to.equal(undefined);
272
+ expect(resolveCustomModelHeaders({})).to.equal(undefined);
273
+ expect(resolveCustomModelHeaders(undefined)).to.equal(undefined);
274
+ expect(resolveCustomModelHeaders('X-Audit-User: theia')).to.equal(undefined);
275
+ expect(resolveCustomModelHeaders([['X-Audit-User', 'theia']])).to.equal(undefined);
276
+ });
263
277
  });
264
278
 
265
279
  describe('isToolCallHtmlAppResult', () => {
@@ -319,6 +319,23 @@ export namespace ToolRequest {
319
319
  (!('required' in obj) || (Array.isArray(obj.required) && obj.required.every(prop => typeof prop === 'string')));
320
320
  }
321
321
  }
322
+
323
+ /**
324
+ * Resolves the `headers` attribute of a custom model preference entry. Since preferences are
325
+ * user-authored JSON, entries with a non-string value are dropped. Returns `undefined` when no
326
+ * usable header remains, so that the default request headers are left untouched. The keys are
327
+ * sorted, so equal header maps stringify identically regardless of their order in the preference.
328
+ */
329
+ export function resolveCustomModelHeaders(headers: unknown): Record<string, string> | undefined {
330
+ if (typeof headers !== 'object' || !headers || Array.isArray(headers)) {
331
+ return undefined;
332
+ }
333
+ const resolved = Object.entries(headers)
334
+ .filter((entry): entry is [string, string] => typeof entry[1] === 'string')
335
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
336
+ return resolved.length > 0 ? Object.fromEntries(resolved) : undefined;
337
+ }
338
+
322
339
  // Anthropic requires at least 50,000 tokens, so use one conservative minimum for all compaction settings.
323
340
  export const SERVER_SIDE_COMPACTION_TOKEN_THRESHOLD_MINIMUM = 50_000;
324
341
 
@@ -650,6 +667,11 @@ export interface LanguageModelMetaData {
650
667
  readonly family?: string;
651
668
  readonly maxInputTokens?: number;
652
669
  readonly maxOutputTokens?: number;
670
+ /**
671
+ * Release date (ms since epoch) as reported by the provider, where it reports one. Lets the model
672
+ * lists offer the newest models first instead of ordering them by name.
673
+ */
674
+ readonly released?: number;
653
675
  readonly status: LanguageModelStatus;
654
676
  readonly reasoningSupport?: ReasoningSupport;
655
677
  /**
@@ -0,0 +1,132 @@
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
+ /**
18
+ * Provider-level state of dynamic model discovery.
19
+ *
20
+ * This is distinct from the per-model {@link LanguageModelStatus} (`ready`/`unavailable`):
21
+ * it describes whether a provider's list of models is currently being fetched, has failed,
22
+ * needs credentials or user consent, etc. It gives the UI a place to communicate fetching
23
+ * progress and error state for the whole provider.
24
+ */
25
+ export type ModelDiscoveryState =
26
+ /** No credential (API key / host) is configured, so nothing can be fetched. */
27
+ | 'no-credentials'
28
+ /** A credential was found in the environment and awaits the user's one-time confirmation. */
29
+ | 'awaiting-consent'
30
+ /** Nothing in progress; no fetch has run yet. */
31
+ | 'idle'
32
+ /** A fetch is currently in flight. */
33
+ | 'fetching'
34
+ /** The last fetch (or cache load) succeeded and models are registered. */
35
+ | 'ready'
36
+ /** The last fetch failed. */
37
+ | 'error'
38
+ /** The model list is configured manually, so the provider is not asked what it offers. */
39
+ | 'overridden';
40
+
41
+ /** Where a provider's effective API key comes from. Used to gate env-var keys behind a one-time consent. */
42
+ export type ApiKeySource = 'preference' | 'environment' | 'none';
43
+
44
+ /**
45
+ * A model a provider reported from its list endpoint.
46
+ *
47
+ * Only {@link id} is guaranteed: the endpoints differ widely in what they report about a model.
48
+ * Anthropic gives a display name and a release date, Google additionally a description, and
49
+ * OpenAI nothing but the id.
50
+ */
51
+ export interface DiscoveredModel {
52
+ /** Provider-native model id, without the `<provider>/` prefix. */
53
+ readonly id: string;
54
+ /** Human-readable name reported by the provider, e.g. `Claude Opus 5`. */
55
+ readonly label?: string;
56
+ /** Longer description reported by the provider. */
57
+ readonly description?: string;
58
+ /** Release date (ms since epoch) reported by the provider, used to order dated variants. */
59
+ readonly released?: number;
60
+ /**
61
+ * Set by a provider that knows better than the generic ranking which of its models should be
62
+ * offered in the model pickers without the user asking. GitHub Copilot serves several vendors, so
63
+ * "the five newest" would offer five models of whichever vendor names its models with the highest
64
+ * numbers; it therefore nominates one per vendor itself. Left unset by the other providers.
65
+ */
66
+ readonly featured?: boolean;
67
+ }
68
+
69
+ /**
70
+ * Result of a provider model-discovery attempt.
71
+ *
72
+ * On a successful live fetch, `fromCache` is `false`. When the live fetch fails but a cached
73
+ * snapshot is available, the cached models are returned with `fromCache: true` and `error` set to
74
+ * the failure reason, so the UI can keep the models usable while indicating they may be stale.
75
+ */
76
+ export interface ModelDiscoveryResult {
77
+ /** The discovered models, in the order they should be registered. */
78
+ readonly models: DiscoveredModel[];
79
+ /** Whether the models came from the cached snapshot because the live fetch failed. */
80
+ readonly fromCache: boolean;
81
+ /** Failure reason when the live fetch failed (even if a cached result was returned). */
82
+ readonly error?: string;
83
+ }
84
+
85
+ /**
86
+ * What to do about a discovery state, offered as a button next to the explanation.
87
+ *
88
+ * Most providers need none: what a missing credential wants is the API key setting, which sits on the
89
+ * same page right above. A provider whose credential is established by a command rather than a
90
+ * preference — GitHub Copilot signs in through its CLI — names that command here.
91
+ */
92
+ export interface ModelDiscoveryAction {
93
+ /** Button label, already localized. */
94
+ readonly label: string;
95
+ readonly commandId: string;
96
+ readonly args?: unknown[];
97
+ }
98
+
99
+ /**
100
+ * Snapshot of a provider's model-discovery state, as observed on the frontend.
101
+ */
102
+ export interface ModelDiscoveryStatus {
103
+ /** Provider id, e.g. `anthropic`. Matches the provider node in the Models configuration category. */
104
+ readonly providerId: string;
105
+ /** Human-readable provider label, e.g. `Anthropic`. */
106
+ readonly label: string;
107
+ /**
108
+ * Prefix used for this provider's registered model ids (e.g. `openai` while the provider id is
109
+ * `openAiOfficial`). Defaults to {@link providerId} when omitted.
110
+ */
111
+ readonly modelIdPrefix?: string;
112
+ readonly state: ModelDiscoveryState;
113
+ /**
114
+ * Overrides the badge label the state would otherwise carry, for a provider the generic wording
115
+ * does not fit: what GitHub Copilot lacks when it has no credential is a sign-in, not an API key.
116
+ */
117
+ readonly stateLabel?: string;
118
+ /** Optional human-readable detail (error message, hint). */
119
+ readonly message?: string;
120
+ /** Timestamp (ms since epoch) of the last successful fetch, if any. */
121
+ readonly lastFetch?: number;
122
+ /** Whether the currently registered models came from a cached snapshot rather than a live fetch. */
123
+ readonly fromCache?: boolean;
124
+ /**
125
+ * What the provider reported about the models it discovered, keyed by id in
126
+ * {@link DiscoveredModel.id}. The registry stays the source of truth for which models are
127
+ * registered; this only carries the extra metadata (label, description) for display.
128
+ */
129
+ readonly discovered?: readonly DiscoveredModel[];
130
+ /** What the user can do about the current state, where a command settles it. */
131
+ readonly action?: ModelDiscoveryAction;
132
+ }