@theia/ai-copilot 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.
- package/README.md +2 -1
- package/lib/browser/copilot-auth-dialog.d.ts.map +1 -1
- package/lib/browser/copilot-auth-dialog.js +7 -7
- package/lib/browser/copilot-auth-dialog.js.map +1 -1
- package/lib/browser/copilot-frontend-application-contribution.d.ts +6 -0
- package/lib/browser/copilot-frontend-application-contribution.d.ts.map +1 -1
- package/lib/browser/copilot-frontend-application-contribution.js +77 -2
- package/lib/browser/copilot-frontend-application-contribution.js.map +1 -1
- package/lib/common/copilot-language-models-manager.d.ts +5 -3
- package/lib/common/copilot-language-models-manager.d.ts.map +1 -1
- package/lib/common/copilot-language-models-manager.js.map +1 -1
- package/lib/node/copilot-language-models-manager-impl.d.ts +24 -2
- package/lib/node/copilot-language-models-manager-impl.d.ts.map +1 -1
- package/lib/node/copilot-language-models-manager-impl.js +79 -4
- package/lib/node/copilot-language-models-manager-impl.js.map +1 -1
- package/lib/node/copilot-language-models-manager-impl.spec.js +47 -4
- package/lib/node/copilot-language-models-manager-impl.spec.js.map +1 -1
- package/lib/node/copilot-sdk-language-model.d.ts +2 -2
- package/lib/node/copilot-sdk-language-model.d.ts.map +1 -1
- package/lib/node/copilot-sdk-language-model.js +4 -4
- package/lib/node/copilot-sdk-language-model.js.map +1 -1
- package/lib/node/copilot-sdk-language-model.spec.js +14 -1
- package/lib/node/copilot-sdk-language-model.spec.js.map +1 -1
- package/lib/node/copilot-sdk-mappers.d.ts +11 -6
- package/lib/node/copilot-sdk-mappers.d.ts.map +1 -1
- package/lib/node/copilot-sdk-mappers.js +28 -17
- package/lib/node/copilot-sdk-mappers.js.map +1 -1
- package/lib/node/copilot-sdk-mappers.spec.js +37 -2
- package/lib/node/copilot-sdk-mappers.spec.js.map +1 -1
- package/lib/node/copilot-sdk-types.d.ts +13 -3
- package/lib/node/copilot-sdk-types.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/browser/copilot-auth-dialog.tsx +8 -8
- package/src/browser/copilot-frontend-application-contribution.ts +82 -2
- package/src/common/copilot-language-models-manager.ts +5 -3
- package/src/node/copilot-language-models-manager-impl.spec.ts +55 -4
- package/src/node/copilot-language-models-manager-impl.ts +86 -5
- package/src/node/copilot-sdk-language-model.spec.ts +17 -3
- package/src/node/copilot-sdk-language-model.ts +5 -4
- package/src/node/copilot-sdk-mappers.spec.ts +41 -2
- package/src/node/copilot-sdk-mappers.ts +31 -20
- package/src/node/copilot-sdk-types.ts +16 -2
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import * as React from '@theia/core/shared/react';
|
|
18
18
|
import { inject, injectable, postConstruct } from '@theia/core/shared/inversify';
|
|
19
|
-
import { DialogProps, DialogError } from '@theia/core/lib/browser/dialogs';
|
|
19
|
+
import { DialogProps, DialogError, Dialog } from '@theia/core/lib/browser/dialogs';
|
|
20
20
|
import { ReactDialog } from '@theia/core/lib/browser/dialogs/react-dialog';
|
|
21
21
|
import { ClipboardService } from '@theia/core/lib/browser/clipboard-service';
|
|
22
22
|
import { WindowService } from '@theia/core/lib/browser/window/window-service';
|
|
@@ -68,22 +68,19 @@ export class CopilotAuthDialog extends ReactDialog<boolean> {
|
|
|
68
68
|
protected init(): void {
|
|
69
69
|
this.titleNode.textContent = this.props.title;
|
|
70
70
|
this.appendAcceptButton(nls.localize('theia/ai/copilot/auth/authorize', 'I have authorized'));
|
|
71
|
-
this.appendCloseButton(
|
|
71
|
+
this.appendCloseButton(Dialog.CANCEL);
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
protected updateButtonStates(): void {
|
|
75
75
|
const isPolling = this.state === 'polling';
|
|
76
76
|
const isSuccess = this.state === 'success';
|
|
77
|
+
// Singleton dialog: button state survives close, so reset it on every render.
|
|
77
78
|
if (this.acceptButton) {
|
|
78
79
|
this.acceptButton.disabled = isPolling || isSuccess;
|
|
79
|
-
|
|
80
|
-
this.acceptButton.style.display = 'none';
|
|
81
|
-
}
|
|
80
|
+
this.acceptButton.style.display = isSuccess ? 'none' : '';
|
|
82
81
|
}
|
|
83
82
|
if (this.closeButton) {
|
|
84
|
-
|
|
85
|
-
this.closeButton.textContent = nls.localizeByDefault('Close');
|
|
86
|
-
}
|
|
83
|
+
this.closeButton.textContent = isSuccess ? nls.localizeByDefault('Close') : Dialog.CANCEL;
|
|
87
84
|
}
|
|
88
85
|
}
|
|
89
86
|
|
|
@@ -105,6 +102,9 @@ export class CopilotAuthDialog extends ReactDialog<boolean> {
|
|
|
105
102
|
protected async initiateFlow(): Promise<void> {
|
|
106
103
|
try {
|
|
107
104
|
this.state = 'loading';
|
|
105
|
+
this.deviceCodeResponse = undefined;
|
|
106
|
+
this.errorMessage = undefined;
|
|
107
|
+
this.copied = false;
|
|
108
108
|
this.update();
|
|
109
109
|
|
|
110
110
|
this.deviceCodeResponse = await this.authService.startSignIn(this.props.enterpriseUrl);
|
|
@@ -22,6 +22,9 @@ import { CopilotAuthService, CopilotLanguageModelsManager, CopilotModelDescripti
|
|
|
22
22
|
import { COPILOT_ENABLED_PREF, COPILOT_ENTERPRISE_URL_PREF, COPILOT_EXECUTABLE_PATH_PREF, COPILOT_MODEL_OVERRIDES_PREF } from '../common/copilot-preferences';
|
|
23
23
|
import { AICorePreferences, PREFERENCE_NAME_MAX_RETRIES } from '@theia/ai-core/lib/common/ai-core-preferences';
|
|
24
24
|
import { CopilotCommands } from './copilot-command-contribution';
|
|
25
|
+
import { ModelDiscoveryStatusService } from '@theia/ai-core/lib/browser';
|
|
26
|
+
|
|
27
|
+
const COPILOT_PROVIDER_LABEL = 'GitHub Copilot';
|
|
25
28
|
|
|
26
29
|
@injectable()
|
|
27
30
|
export class CopilotFrontendApplicationContribution implements FrontendApplicationContribution {
|
|
@@ -44,11 +47,19 @@ export class CopilotFrontendApplicationContribution implements FrontendApplicati
|
|
|
44
47
|
@inject(CommandService)
|
|
45
48
|
protected readonly commandService: CommandService;
|
|
46
49
|
|
|
50
|
+
@inject(ModelDiscoveryStatusService)
|
|
51
|
+
protected readonly discoveryStatus: ModelDiscoveryStatusService;
|
|
52
|
+
|
|
47
53
|
protected prevModels: string[] = [];
|
|
48
54
|
protected useAutoDiscovery = false;
|
|
49
55
|
|
|
50
56
|
onStart(): void {
|
|
51
57
|
this.preferenceService.ready.then(async () => {
|
|
58
|
+
this.discoveryStatus.registerProvider({
|
|
59
|
+
providerId: COPILOT_PROVIDER_ID,
|
|
60
|
+
label: COPILOT_PROVIDER_LABEL,
|
|
61
|
+
refresh: () => this.initializeModels()
|
|
62
|
+
});
|
|
52
63
|
// Before anything reaches for the CLI: the backend cannot read the preferences itself.
|
|
53
64
|
await this.updateExecutablePath();
|
|
54
65
|
if (this.isCopilotEnabled()) {
|
|
@@ -56,8 +67,9 @@ export class CopilotFrontendApplicationContribution implements FrontendApplicati
|
|
|
56
67
|
if (authState.migrationRequired) {
|
|
57
68
|
this.notifySignInRequired();
|
|
58
69
|
}
|
|
59
|
-
await this.initializeModels();
|
|
60
70
|
}
|
|
71
|
+
// Also when disabled: that is a state of its own on the provider's page, not the absence of one.
|
|
72
|
+
await this.initializeModels();
|
|
61
73
|
|
|
62
74
|
this.preferenceService.onPreferenceChanged(event => {
|
|
63
75
|
if (event.preferenceName === COPILOT_EXECUTABLE_PATH_PREF) {
|
|
@@ -72,6 +84,7 @@ export class CopilotFrontendApplicationContribution implements FrontendApplicati
|
|
|
72
84
|
this.initializeModels();
|
|
73
85
|
} else {
|
|
74
86
|
this.removeAllCopilotModels();
|
|
87
|
+
this.initializeModels();
|
|
75
88
|
}
|
|
76
89
|
} else if (event.preferenceName === COPILOT_ENTERPRISE_URL_PREF) {
|
|
77
90
|
// The domain is only read at sign-in time, so a change while signed in has no effect
|
|
@@ -96,6 +109,7 @@ export class CopilotFrontendApplicationContribution implements FrontendApplicati
|
|
|
96
109
|
await this.discoverAndRegisterModels();
|
|
97
110
|
} else {
|
|
98
111
|
this.removeAllCopilotModels();
|
|
112
|
+
this.setSignInRequiredStatus();
|
|
99
113
|
}
|
|
100
114
|
}
|
|
101
115
|
});
|
|
@@ -158,9 +172,41 @@ export class CopilotFrontendApplicationContribution implements FrontendApplicati
|
|
|
158
172
|
}
|
|
159
173
|
}
|
|
160
174
|
|
|
175
|
+
/** The state of a provider whose credential is a sign-in that has not happened, with the way to fix it. */
|
|
176
|
+
protected setSignInRequiredStatus(): void {
|
|
177
|
+
this.discoveryStatus.updateStatus(COPILOT_PROVIDER_ID, {
|
|
178
|
+
state: 'no-credentials',
|
|
179
|
+
// What Copilot lacks is a sign-in, not an API key, so it says so rather than letting the
|
|
180
|
+
// state speak for it.
|
|
181
|
+
stateLabel: nls.localizeByDefault('Not signed in'),
|
|
182
|
+
message: nls.localize('theia/ai/copilot/discovery/signedOut',
|
|
183
|
+
'Not signed in to GitHub Copilot. Sign in to discover the models it offers.'),
|
|
184
|
+
action: { label: CopilotCommands.SIGN_IN.label!, commandId: CopilotCommands.SIGN_IN.id }
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
161
188
|
protected async initializeModels(): Promise<void> {
|
|
189
|
+
if (!this.isCopilotEnabled()) {
|
|
190
|
+
this.discoveryStatus.updateStatus(COPILOT_PROVIDER_ID, {
|
|
191
|
+
state: 'no-credentials',
|
|
192
|
+
stateLabel: nls.localizeByDefault('Disabled'),
|
|
193
|
+
message: nls.localize('theia/ai/copilot/discovery/disabled', 'The GitHub Copilot provider is disabled. Enable it to discover models.'),
|
|
194
|
+
action: undefined
|
|
195
|
+
});
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
162
198
|
const configuredModels = this.preferenceService.get<string[]>(COPILOT_MODEL_OVERRIDES_PREF, []);
|
|
163
199
|
if (configuredModels.length > 0) {
|
|
200
|
+
this.discoveryStatus.updateStatus(COPILOT_PROVIDER_ID, {
|
|
201
|
+
state: 'overridden',
|
|
202
|
+
stateLabel: undefined,
|
|
203
|
+
discovered: configuredModels.map(id => ({ id })),
|
|
204
|
+
message: nls.localize('theia/ai/copilot/discovery/overridden',
|
|
205
|
+
'The model list is configured manually. Clear the model overrides to discover the models from GitHub Copilot again.'),
|
|
206
|
+
lastFetch: undefined,
|
|
207
|
+
fromCache: false,
|
|
208
|
+
action: undefined
|
|
209
|
+
});
|
|
164
210
|
this.useAutoDiscovery = false;
|
|
165
211
|
this.manager.createOrUpdateLanguageModels(
|
|
166
212
|
...configuredModels.map((modelId: string) => this.createCopilotModelDescription(modelId))
|
|
@@ -173,7 +219,32 @@ export class CopilotFrontendApplicationContribution implements FrontendApplicati
|
|
|
173
219
|
}
|
|
174
220
|
|
|
175
221
|
protected async discoverAndRegisterModels(): Promise<void> {
|
|
176
|
-
|
|
222
|
+
// Runs are queued rather than overlapped: startup, a key change and a manual refresh can all
|
|
223
|
+
// ask within a moment of each other, and two runs in flight would compute what to unregister
|
|
224
|
+
// from the same stale list and leave the registry disagreeing with the provider.
|
|
225
|
+
this.discovering = this.discovering.then(() => this.runDiscovery().catch(error => this.discoveryStatus.reportError(COPILOT_PROVIDER_ID, error)));
|
|
226
|
+
return this.discovering;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
protected discovering: Promise<void> = Promise.resolve();
|
|
230
|
+
|
|
231
|
+
protected async runDiscovery(): Promise<void> {
|
|
232
|
+
// Signing in is Copilot's equivalent of setting an API key, so a missing sign-in reads like a
|
|
233
|
+
// missing credential rather than like a failure: the CLI would answer the list call with an
|
|
234
|
+
// authorization error, which says the same thing in a way nobody can act on.
|
|
235
|
+
if (!(await this.authService.getAuthState()).isAuthenticated) {
|
|
236
|
+
this.setSignInRequiredStatus();
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
this.discoveryStatus.updateStatus(COPILOT_PROVIDER_ID, { state: 'fetching', stateLabel: undefined, message: undefined, action: undefined });
|
|
240
|
+
const { models, error } = await this.manager.fetchAvailableModels();
|
|
241
|
+
if (error) {
|
|
242
|
+
// The models of an earlier discovery stay registered; the manager already marks them
|
|
243
|
+
// unavailable, so they are visible as such rather than silently gone.
|
|
244
|
+
this.discoveryStatus.updateStatus(COPILOT_PROVIDER_ID, { state: 'error', stateLabel: undefined, message: error, action: undefined });
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const modelIds = models.map(model => model.id);
|
|
177
248
|
if (modelIds.length > 0) {
|
|
178
249
|
const modelsToRemove = this.prevModels.filter(m => !modelIds.includes(m));
|
|
179
250
|
if (modelsToRemove.length > 0) {
|
|
@@ -186,6 +257,15 @@ export class CopilotFrontendApplicationContribution implements FrontendApplicati
|
|
|
186
257
|
);
|
|
187
258
|
this.prevModels = [...modelIds];
|
|
188
259
|
}
|
|
260
|
+
this.discoveryStatus.updateStatus(COPILOT_PROVIDER_ID, {
|
|
261
|
+
state: 'ready',
|
|
262
|
+
stateLabel: undefined,
|
|
263
|
+
discovered: models,
|
|
264
|
+
message: undefined,
|
|
265
|
+
lastFetch: Date.now(),
|
|
266
|
+
fromCache: false,
|
|
267
|
+
action: undefined
|
|
268
|
+
});
|
|
189
269
|
}
|
|
190
270
|
|
|
191
271
|
protected removeAllCopilotModels(): void {
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
|
15
15
|
// *****************************************************************************
|
|
16
16
|
|
|
17
|
+
import { ModelDiscoveryResult } from '@theia/ai-core/lib/common';
|
|
17
18
|
export const COPILOT_LANGUAGE_MODELS_MANAGER_PATH = '/services/copilot/language-model-manager';
|
|
18
19
|
export const CopilotLanguageModelsManager = Symbol('CopilotLanguageModelsManager');
|
|
19
20
|
|
|
@@ -49,8 +50,9 @@ export interface CopilotLanguageModelsManager {
|
|
|
49
50
|
*/
|
|
50
51
|
refreshModelsStatus(): Promise<void>;
|
|
51
52
|
/**
|
|
52
|
-
* Fetches the
|
|
53
|
-
*
|
|
53
|
+
* Fetches the models the Copilot CLI offers. Requires the CLI to be signed in; a failure is
|
|
54
|
+
* reported as {@link ModelDiscoveryResult.error} with no models rather than thrown, since the
|
|
55
|
+
* CLI being absent or signed out is an expected state rather than a fault.
|
|
54
56
|
*/
|
|
55
|
-
|
|
57
|
+
fetchAvailableModels(): Promise<ModelDiscoveryResult>;
|
|
56
58
|
}
|
|
@@ -59,6 +59,10 @@ class TestableCopilotLanguageModelsManagerImpl extends CopilotLanguageModelsMana
|
|
|
59
59
|
listFailure: Error | undefined;
|
|
60
60
|
modelIds: string[] = ['gpt-5'];
|
|
61
61
|
|
|
62
|
+
callVendorOf(id: string): string | undefined {
|
|
63
|
+
return this.vendorOf(id);
|
|
64
|
+
}
|
|
65
|
+
|
|
62
66
|
constructor(readonly registry: FakeRegistry) {
|
|
63
67
|
super();
|
|
64
68
|
Object.assign(this, {
|
|
@@ -104,7 +108,9 @@ describe('CopilotLanguageModelsManagerImpl - status', () => {
|
|
|
104
108
|
|
|
105
109
|
it('should report models as unavailable when they cannot be listed, rather than as ready', async () => {
|
|
106
110
|
manager.listFailure = new Error('not authorized to use this Copilot feature');
|
|
107
|
-
|
|
111
|
+
const result = await manager.fetchAvailableModels();
|
|
112
|
+
expect(result.models).to.be.empty;
|
|
113
|
+
expect(result.error).to.contain('not authorized');
|
|
108
114
|
const status = await manager.callCalculateStatus();
|
|
109
115
|
expect(status.status).to.equal('unavailable');
|
|
110
116
|
expect(status.message).to.contain('not authorized');
|
|
@@ -113,7 +119,7 @@ describe('CopilotLanguageModelsManagerImpl - status', () => {
|
|
|
113
119
|
it('should reflect a failed listing in the models that are already registered', async () => {
|
|
114
120
|
await manager.createOrUpdateLanguageModels({ id: 'copilot/gpt-5', model: 'gpt-5', maxRetries: 3 });
|
|
115
121
|
manager.listFailure = new Error('socket hang up');
|
|
116
|
-
await manager.
|
|
122
|
+
await manager.fetchAvailableModels();
|
|
117
123
|
expect(manager.registry.patches.map(patch => patch.id)).to.deep.equal(['copilot/gpt-5']);
|
|
118
124
|
expect(manager.registry.models[0].status).to.deep.include({ status: 'unavailable' });
|
|
119
125
|
});
|
|
@@ -121,12 +127,57 @@ describe('CopilotLanguageModelsManagerImpl - status', () => {
|
|
|
121
127
|
it('should report ready again once the models can be listed', async () => {
|
|
122
128
|
await manager.createOrUpdateLanguageModels({ id: 'copilot/gpt-5', model: 'gpt-5', maxRetries: 3 });
|
|
123
129
|
manager.listFailure = new Error('socket hang up');
|
|
124
|
-
await manager.
|
|
130
|
+
await manager.fetchAvailableModels();
|
|
125
131
|
manager.listFailure = undefined;
|
|
126
|
-
expect(await manager.
|
|
132
|
+
expect((await manager.fetchAvailableModels()).models).to.deep.equal([{ id: 'gpt-5', featured: true }]);
|
|
127
133
|
expect(manager.registry.models[0].status).to.deep.equal({ status: 'ready' });
|
|
128
134
|
});
|
|
129
135
|
|
|
136
|
+
it('nominates auto and one model of each major vendor before a second of any', async () => {
|
|
137
|
+
manager.modelIds = ['auto', 'gpt-5', 'gpt-4.1', 'claude-sonnet-4.5', 'claude-haiku-4', 'gemini-2.5-pro', 'gpt-5-2026-04-17'];
|
|
138
|
+
const featured = (await manager.fetchAvailableModels()).models.filter(model => model.featured).map(model => model.id);
|
|
139
|
+
// Five in total: auto, the newest of each vendor, then the next of the first vendor with one left.
|
|
140
|
+
expect(featured).to.have.members(['auto', 'gpt-5', 'claude-sonnet-4.5', 'gemini-2.5-pro', 'claude-haiku-4']);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('fills the list to five even when the CLI offers no auto', async () => {
|
|
144
|
+
manager.modelIds = ['gpt-5', 'gpt-4.1', 'claude-sonnet-4.5', 'claude-haiku-4', 'gemini-3-pro', 'gemini-2.5-pro'];
|
|
145
|
+
const featured = (await manager.fetchAvailableModels()).models.filter(model => model.featured).map(model => model.id);
|
|
146
|
+
expect(featured).to.have.members(['gpt-5', 'claude-sonnet-4.5', 'gemini-3-pro', 'gpt-4.1', 'claude-haiku-4']);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('nominates no more than the five, however much one vendor offers', async () => {
|
|
150
|
+
manager.modelIds = ['gpt-5', 'gpt-5-mini', 'gpt-4.1', 'gpt-4o', 'o5-preview', 'chatgpt-5-latest'];
|
|
151
|
+
const featured = (await manager.fetchAvailableModels()).models.filter(model => model.featured).map(model => model.id);
|
|
152
|
+
expect(featured).to.have.lengthOf(5);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('counts the reasoning models as OpenAI, rather than as a vendor of their own', () => {
|
|
156
|
+
expect(manager.callVendorOf('o5-preview')).to.equal('openai');
|
|
157
|
+
expect(manager.callVendorOf('gpt-4.1')).to.equal('openai');
|
|
158
|
+
expect(manager.callVendorOf('claude-sonnet-4.5')).to.equal('anthropic');
|
|
159
|
+
expect(manager.callVendorOf('grok-code-fast-1')).to.equal(undefined);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('nominates nothing for the vendors beyond the three, which stay selectable but not preselected', async () => {
|
|
163
|
+
manager.modelIds = ['claude-sonnet-4.5', 'grok-code-fast-1', 'mistral-large', 'llama-4-maverick'];
|
|
164
|
+
const result = await manager.fetchAvailableModels();
|
|
165
|
+
expect(result.models.map(model => model.id)).to.have.lengthOf(4);
|
|
166
|
+
expect(result.models.filter(model => model.featured).map(model => model.id)).to.deep.equal(['claude-sonnet-4.5']);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('recognises a vendor-qualified id, in case the CLI reports them that way', async () => {
|
|
170
|
+
manager.modelIds = ['google/gemini-2.5-pro', 'anthropic/claude-sonnet-4.5'];
|
|
171
|
+
const featured = (await manager.fetchAvailableModels()).models.filter(model => model.featured).map(model => model.id);
|
|
172
|
+
expect(featured).to.have.members(['google/gemini-2.5-pro', 'anthropic/claude-sonnet-4.5']);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('never nominates a release-pinned id, whose undated form is nominated instead', async () => {
|
|
176
|
+
manager.modelIds = ['gpt-5', 'gpt-5-2026-04-17'];
|
|
177
|
+
const featured = (await manager.fetchAvailableModels()).models.filter(model => model.featured).map(model => model.id);
|
|
178
|
+
expect(featured).to.deep.equal(['gpt-5']);
|
|
179
|
+
});
|
|
180
|
+
|
|
130
181
|
it('should update an existing model instead of registering it twice', async () => {
|
|
131
182
|
await manager.createOrUpdateLanguageModels({ id: 'copilot/gpt-5', model: 'gpt-5', maxRetries: 3 });
|
|
132
183
|
await manager.createOrUpdateLanguageModels({ id: 'copilot/gpt-5', model: 'gpt-5', maxRetries: 5 });
|
|
@@ -14,10 +14,28 @@
|
|
|
14
14
|
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
|
15
15
|
// *****************************************************************************
|
|
16
16
|
|
|
17
|
-
import { LanguageModelRegistry, LanguageModelStatus } from '@theia/ai-core';
|
|
17
|
+
import { DiscoveredModels, LanguageModelRegistry, LanguageModelStatus, ModelDiscoveryResult } from '@theia/ai-core';
|
|
18
18
|
import { DisposableCollection, ILogger, nls } from '@theia/core';
|
|
19
19
|
import { inject, injectable, named, postConstruct, preDestroy } from '@theia/core/shared/inversify';
|
|
20
20
|
import { CopilotLanguageModelsManager, CopilotModelDescription, COPILOT_PROVIDER_ID } from '../common';
|
|
21
|
+
|
|
22
|
+
/** The id under which Copilot offers its own model selection; always worth showing. */
|
|
23
|
+
const COPILOT_AUTO_MODEL_ID = 'auto';
|
|
24
|
+
|
|
25
|
+
/** How many models Copilot nominates, matching what the other providers feature. */
|
|
26
|
+
const NOMINATED_MODEL_COUNT = 5;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The vendors one Copilot model each is nominated for, recognised by the prefix of the model id.
|
|
30
|
+
* Deliberately the three whose models the other providers of this application offer directly: those
|
|
31
|
+
* are the ones a user coming to the chat input expects to switch between. Copilot carries more, and
|
|
32
|
+
* they remain registered and selectable — just not preselected.
|
|
33
|
+
*/
|
|
34
|
+
const NOMINATED_VENDORS: ReadonlyArray<{ vendor: string; matches: RegExp }> = [
|
|
35
|
+
{ vendor: 'anthropic', matches: /^claude/i },
|
|
36
|
+
{ vendor: 'openai', matches: /^(gpt|chatgpt|o\d)/i },
|
|
37
|
+
{ vendor: 'google', matches: /^gemini/i }
|
|
38
|
+
];
|
|
21
39
|
import { CopilotSdkLanguageModel } from './copilot-sdk-language-model';
|
|
22
40
|
import { CopilotSdkClientProvider } from './copilot-sdk-client-provider';
|
|
23
41
|
import { CopilotAuthServiceImpl } from './copilot-auth-service-impl';
|
|
@@ -130,19 +148,82 @@ export class CopilotLanguageModelsManagerImpl implements CopilotLanguageModelsMa
|
|
|
130
148
|
}
|
|
131
149
|
}
|
|
132
150
|
|
|
133
|
-
async
|
|
151
|
+
async fetchAvailableModels(): Promise<ModelDiscoveryResult> {
|
|
134
152
|
try {
|
|
135
153
|
const modelIds = await this.sdkClientProvider.listModelIds();
|
|
136
154
|
this.logger.info(`Copilot: discovered ${modelIds.length} models [${modelIds.join(', ')}]`);
|
|
137
155
|
await this.setDiscoveryFailure(undefined);
|
|
138
|
-
|
|
156
|
+
// The CLI reports nothing but the id: no display name, and no release date to order by.
|
|
157
|
+
const featured = this.selectFeaturedModelIds(modelIds);
|
|
158
|
+
return { models: modelIds.map(id => ({ id, featured: featured.has(id) })), fromCache: false };
|
|
139
159
|
} catch (error) {
|
|
160
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
140
161
|
this.logger.warn('Copilot: failed to fetch available models via the Copilot CLI:', error);
|
|
141
|
-
await this.setDiscoveryFailure(
|
|
142
|
-
return [];
|
|
162
|
+
await this.setDiscoveryFailure(message);
|
|
163
|
+
return { models: [], fromCache: false, error: message };
|
|
143
164
|
}
|
|
144
165
|
}
|
|
145
166
|
|
|
167
|
+
/**
|
|
168
|
+
* Nominates the models the chat input should show without the user asking, up to
|
|
169
|
+
* {@link NOMINATED_MODEL_COUNT}. Copilot serves models of several vendors under one provider, so
|
|
170
|
+
* ranking them against each other by the numbers in their ids would show several models of
|
|
171
|
+
* whichever vendor counts highest and none of the others. The list is therefore filled vendor by
|
|
172
|
+
* vendor: `auto` first, which lets Copilot itself choose and is the entry most users want, then the
|
|
173
|
+
* newest model of each major vendor, then the next of each in turn until the list is full.
|
|
174
|
+
*
|
|
175
|
+
* Everything else Copilot carries is left to be chosen deliberately: the list is meant to be the
|
|
176
|
+
* handful of models one switches between while chatting, not a survey of the catalogue.
|
|
177
|
+
*
|
|
178
|
+
* Release-pinned ids are left out: the undated id of the same model is nominated, and pinning a
|
|
179
|
+
* release is the deliberate choice the check on its row makes.
|
|
180
|
+
*/
|
|
181
|
+
protected selectFeaturedModelIds(modelIds: string[]): Set<string> {
|
|
182
|
+
const featured = new Set<string>();
|
|
183
|
+
const byVendor = new Map<string, string[]>();
|
|
184
|
+
for (const id of modelIds) {
|
|
185
|
+
if (id === COPILOT_AUTO_MODEL_ID) {
|
|
186
|
+
featured.add(id);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (DiscoveredModels.undatedId(id) !== undefined) {
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const vendor = this.vendorOf(id);
|
|
193
|
+
if (!vendor) {
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
byVendor.set(vendor, [...byVendor.get(vendor) ?? [], id]);
|
|
197
|
+
}
|
|
198
|
+
// Newest first within each vendor, the vendors themselves in the order they are named.
|
|
199
|
+
const ranked = NOMINATED_VENDORS.map(({ vendor }) =>
|
|
200
|
+
[...byVendor.get(vendor) ?? []].sort((left, right) => DiscoveredModels.compareByVersion(left, right)));
|
|
201
|
+
for (let rank = 0; featured.size < NOMINATED_MODEL_COUNT && ranked.some(ids => ids.length > rank); rank++) {
|
|
202
|
+
for (const ids of ranked) {
|
|
203
|
+
if (featured.size >= NOMINATED_MODEL_COUNT) {
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
if (ids[rank] !== undefined) {
|
|
207
|
+
featured.add(ids[rank]);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return featured;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The vendor a Copilot model id belongs to, or `undefined` for a vendor that is not nominated.
|
|
216
|
+
* Matching on the id is all there is — the CLI reports nothing else — and it is deliberately narrow:
|
|
217
|
+
* a model of a vendor this does not name is still registered and still selectable, it is simply not
|
|
218
|
+
* one of the few the chat input starts with.
|
|
219
|
+
*/
|
|
220
|
+
protected vendorOf(id: string): string | undefined {
|
|
221
|
+
// Matched against the last segment, so that an id the CLI qualifies with its vendor
|
|
222
|
+
// (`google/gemini-2.5-pro`) is recognised as readily as a bare one.
|
|
223
|
+
const name = id.substring(id.lastIndexOf('/') + 1);
|
|
224
|
+
return NOMINATED_VENDORS.find(candidate => candidate.matches.test(name))?.vendor;
|
|
225
|
+
}
|
|
226
|
+
|
|
146
227
|
/**
|
|
147
228
|
* Records the outcome of the last model discovery and reflects it in the status of the models.
|
|
148
229
|
*/
|
|
@@ -49,9 +49,10 @@ class TestableCopilotSdkLanguageModel extends CopilotSdkLanguageModel {
|
|
|
49
49
|
* Runs a request against a session that reports itself idle as soon as it was sent to, and
|
|
50
50
|
* reports what the session was configured with, what it was sent, and what was discarded.
|
|
51
51
|
*/
|
|
52
|
-
async captureTurn(request: UserRequest): Promise<{ config: Record<string, unknown>, prompt?: string, deleted?: string }> {
|
|
52
|
+
async captureTurn(request: UserRequest): Promise<{ config: Record<string, unknown>, prompt?: string, attachments?: unknown[], deleted?: string }> {
|
|
53
53
|
let config: Record<string, unknown> = {};
|
|
54
54
|
let prompt: string | undefined;
|
|
55
|
+
let attachments: unknown[] | undefined;
|
|
55
56
|
let deleted: string | undefined;
|
|
56
57
|
const listeners = new Map<string, (event: unknown) => void>();
|
|
57
58
|
const session = {
|
|
@@ -60,8 +61,9 @@ class TestableCopilotSdkLanguageModel extends CopilotSdkLanguageModel {
|
|
|
60
61
|
listeners.set(event, handler);
|
|
61
62
|
return () => listeners.delete(event);
|
|
62
63
|
},
|
|
63
|
-
send: async (message: { prompt: string }) => {
|
|
64
|
+
send: async (message: { prompt: string, attachments?: unknown[] }) => {
|
|
64
65
|
prompt = message.prompt;
|
|
66
|
+
attachments = message.attachments;
|
|
65
67
|
listeners.get('session.idle')?.({});
|
|
66
68
|
},
|
|
67
69
|
abort: async () => { },
|
|
@@ -81,7 +83,7 @@ class TestableCopilotSdkLanguageModel extends CopilotSdkLanguageModel {
|
|
|
81
83
|
for await (const part of response.stream) {
|
|
82
84
|
expect(part).to.exist;
|
|
83
85
|
}
|
|
84
|
-
return { config, prompt, deleted };
|
|
86
|
+
return { config, prompt, attachments, deleted };
|
|
85
87
|
}
|
|
86
88
|
}
|
|
87
89
|
|
|
@@ -257,6 +259,18 @@ describe('CopilotSdkLanguageModel - turn', () => {
|
|
|
257
259
|
const { deleted } = await model.captureTurn(request);
|
|
258
260
|
expect(deleted).to.equal('test-session');
|
|
259
261
|
});
|
|
262
|
+
|
|
263
|
+
it('should send a base64 image of the request as a blob attachment', async () => {
|
|
264
|
+
const requestWithImage = {
|
|
265
|
+
messages: [
|
|
266
|
+
{ actor: 'user', type: 'text', text: 'what is this?' },
|
|
267
|
+
{ actor: 'user', type: 'image', image: { base64data: 'aGk=', mimeType: 'image/png' } }
|
|
268
|
+
]
|
|
269
|
+
} as unknown as UserRequest;
|
|
270
|
+
const { prompt, attachments } = await model.captureTurn(requestWithImage);
|
|
271
|
+
expect(prompt).to.equal('what is this?');
|
|
272
|
+
expect(attachments).to.deep.equal([{ type: 'blob', data: 'aGk=', mimeType: 'image/png' }]);
|
|
273
|
+
});
|
|
260
274
|
});
|
|
261
275
|
|
|
262
276
|
describe('CopilotStreamSink', () => {
|
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
UserRequest
|
|
25
25
|
} from '@theia/ai-core';
|
|
26
26
|
import { CancellationToken, ILogger } from '@theia/core';
|
|
27
|
-
import type { CopilotClient, CopilotSession, PermissionHandler, Tool } from './copilot-sdk-types';
|
|
27
|
+
import type { BlobMessageAttachment, CopilotClient, CopilotSession, PermissionHandler, Tool } from './copilot-sdk-types';
|
|
28
28
|
import { buildSdkPrompt, buildSdkSystemMessage } from './copilot-sdk-mappers';
|
|
29
29
|
|
|
30
30
|
/**
|
|
@@ -155,7 +155,7 @@ export class CopilotSdkLanguageModel implements LanguageModel {
|
|
|
155
155
|
|
|
156
156
|
async request(request: UserRequest, cancellationToken?: CancellationToken): Promise<LanguageModelResponse> {
|
|
157
157
|
const client = await this.clientProvider();
|
|
158
|
-
const { systemText, prompt } = buildSdkPrompt(request.messages);
|
|
158
|
+
const { systemText, prompt, attachments } = buildSdkPrompt(request.messages);
|
|
159
159
|
const sink = new CopilotStreamSink();
|
|
160
160
|
|
|
161
161
|
const tools = this.createTools(request.tools ?? [], sink);
|
|
@@ -177,7 +177,7 @@ export class CopilotSdkLanguageModel implements LanguageModel {
|
|
|
177
177
|
onPermissionRequest: approveTheiaTools
|
|
178
178
|
});
|
|
179
179
|
|
|
180
|
-
return { stream: this.streamResponse(client, session, prompt, sink, cancellationToken) };
|
|
180
|
+
return { stream: this.streamResponse(client, session, prompt, attachments, sink, cancellationToken) };
|
|
181
181
|
}
|
|
182
182
|
|
|
183
183
|
/**
|
|
@@ -235,6 +235,7 @@ export class CopilotSdkLanguageModel implements LanguageModel {
|
|
|
235
235
|
client: CopilotClient,
|
|
236
236
|
session: CopilotSession,
|
|
237
237
|
prompt: string,
|
|
238
|
+
attachments: BlobMessageAttachment[] | undefined,
|
|
238
239
|
sink: CopilotStreamSink,
|
|
239
240
|
cancellationToken?: CancellationToken
|
|
240
241
|
): AsyncIterable<LanguageModelStreamResponsePart> {
|
|
@@ -292,7 +293,7 @@ export class CopilotSdkLanguageModel implements LanguageModel {
|
|
|
292
293
|
} else {
|
|
293
294
|
// Deliberately not awaited: the turn only completes after the tool loop has run, and
|
|
294
295
|
// its parts have to be yielded while that happens.
|
|
295
|
-
session.send({ prompt }).catch(error => sink.finish(error));
|
|
296
|
+
session.send({ prompt, attachments }).catch(error => sink.finish(error));
|
|
296
297
|
}
|
|
297
298
|
yield* sink.drain();
|
|
298
299
|
if (inputTokens !== undefined || outputTokens !== undefined) {
|
|
@@ -44,14 +44,16 @@ describe('copilot-sdk-mappers - selectSdkModelIds', () => {
|
|
|
44
44
|
expect(result).to.deep.equal(['a', 'b']);
|
|
45
45
|
});
|
|
46
46
|
|
|
47
|
-
it('
|
|
47
|
+
it('keeps a dated release alongside its family, so a release can be pinned', () => {
|
|
48
48
|
const result = selectSdkModelIds([
|
|
49
49
|
model('gpt-5'),
|
|
50
50
|
model('gpt-5-2026-04-17'),
|
|
51
51
|
model('claude-sonnet-5'),
|
|
52
52
|
model('claude-sonnet-5-20260514')
|
|
53
53
|
]);
|
|
54
|
-
|
|
54
|
+
// Which of them the model pickers offer is decided by the favorite models service, which
|
|
55
|
+
// features the families; the releases stay selectable for whoever wants one.
|
|
56
|
+
expect(result).to.deep.equal(['gpt-5', 'gpt-5-2026-04-17', 'claude-sonnet-5', 'claude-sonnet-5-20260514']);
|
|
55
57
|
});
|
|
56
58
|
|
|
57
59
|
it('keeps a dated release that is the only way to select that model', () => {
|
|
@@ -120,6 +122,43 @@ describe('copilot-sdk-mappers - buildSdkPrompt', () => {
|
|
|
120
122
|
'User: run it\n\nAssistant: [tool call: foo {"x":1}]\n\nUser: [tool result: done]'
|
|
121
123
|
);
|
|
122
124
|
});
|
|
125
|
+
|
|
126
|
+
it('turns a base64 user image into a blob attachment and drops it from the prompt text', () => {
|
|
127
|
+
const messages: LanguageModelMessage[] = [
|
|
128
|
+
{ actor: 'user', type: 'text', text: 'what is this?' },
|
|
129
|
+
{ actor: 'user', type: 'image', image: { base64data: 'aGk=', mimeType: 'image/png' } }
|
|
130
|
+
];
|
|
131
|
+
const result = buildSdkPrompt(messages);
|
|
132
|
+
expect(result.prompt).to.equal('what is this?');
|
|
133
|
+
expect(result.attachments).to.deep.equal([{ type: 'blob', data: 'aGk=', mimeType: 'image/png' }]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('collects multiple base64 images in message order', () => {
|
|
137
|
+
const messages: LanguageModelMessage[] = [
|
|
138
|
+
{ actor: 'user', type: 'image', image: { base64data: 'AAA=', mimeType: 'image/png' } },
|
|
139
|
+
{ actor: 'user', type: 'image', image: { base64data: 'BBB=', mimeType: 'image/jpeg' } }
|
|
140
|
+
];
|
|
141
|
+
const result = buildSdkPrompt(messages);
|
|
142
|
+
expect(result.attachments).to.deep.equal([
|
|
143
|
+
{ type: 'blob', data: 'AAA=', mimeType: 'image/png' },
|
|
144
|
+
{ type: 'blob', data: 'BBB=', mimeType: 'image/jpeg' }
|
|
145
|
+
]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('leaves attachments undefined when there are no images', () => {
|
|
149
|
+
const result = buildSdkPrompt([{ actor: 'user', type: 'text', text: 'hi' }]);
|
|
150
|
+
expect(result.attachments).to.be.undefined;
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('does not attach a URL image and renders it as an omitted placeholder instead', () => {
|
|
154
|
+
const messages: LanguageModelMessage[] = [
|
|
155
|
+
{ actor: 'user', type: 'text', text: 'look' },
|
|
156
|
+
{ actor: 'user', type: 'image', image: { url: 'https://example.com/a.png' } }
|
|
157
|
+
];
|
|
158
|
+
const result = buildSdkPrompt(messages);
|
|
159
|
+
expect(result.attachments).to.be.undefined;
|
|
160
|
+
expect(result.prompt).to.equal('User: look\n\nUser: [image omitted]');
|
|
161
|
+
});
|
|
123
162
|
});
|
|
124
163
|
|
|
125
164
|
describe('copilot-sdk-mappers - buildSdkSystemMessage', () => {
|