@theia/ai-core 1.74.0-next.9 → 1.74.1
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/lib/browser/ai-configuration-service-impl.d.ts +32 -0
- package/lib/browser/ai-configuration-service-impl.d.ts.map +1 -0
- package/lib/browser/ai-configuration-service-impl.js +120 -0
- package/lib/browser/ai-configuration-service-impl.js.map +1 -0
- package/lib/browser/ai-configuration-service-impl.spec.d.ts +2 -0
- package/lib/browser/ai-configuration-service-impl.spec.d.ts.map +1 -0
- package/lib/browser/ai-configuration-service-impl.spec.js +214 -0
- package/lib/browser/ai-configuration-service-impl.spec.js.map +1 -0
- package/lib/browser/ai-core-frontend-module.d.ts.map +1 -1
- package/lib/browser/ai-core-frontend-module.js +4 -0
- package/lib/browser/ai-core-frontend-module.js.map +1 -1
- package/lib/browser/ai-settings-service.d.ts +2 -5
- package/lib/browser/ai-settings-service.d.ts.map +1 -1
- package/lib/browser/ai-settings-service.js +8 -15
- package/lib/browser/ai-settings-service.js.map +1 -1
- package/lib/browser/frontend-language-model-alias-registry.d.ts +2 -4
- package/lib/browser/frontend-language-model-alias-registry.d.ts.map +1 -1
- package/lib/browser/frontend-language-model-alias-registry.js +21 -26
- package/lib/browser/frontend-language-model-alias-registry.js.map +1 -1
- package/lib/browser/frontend-language-model-service.d.ts +2 -3
- package/lib/browser/frontend-language-model-service.d.ts.map +1 -1
- package/lib/browser/frontend-language-model-service.js +6 -6
- package/lib/browser/frontend-language-model-service.js.map +1 -1
- package/lib/browser/index.d.ts +1 -0
- package/lib/browser/index.d.ts.map +1 -1
- package/lib/browser/index.js +1 -0
- package/lib/browser/index.js.map +1 -1
- package/lib/browser/prompt-variable-contribution.d.ts.map +1 -1
- package/lib/browser/prompt-variable-contribution.js +13 -16
- package/lib/browser/prompt-variable-contribution.js.map +1 -1
- package/lib/browser/prompt-variable-contribution.spec.js +64 -2
- package/lib/browser/prompt-variable-contribution.spec.js.map +1 -1
- package/lib/browser/trust-aware-preference-reader.d.ts +13 -1
- package/lib/browser/trust-aware-preference-reader.d.ts.map +1 -1
- package/lib/browser/trust-aware-preference-reader.js +21 -2
- package/lib/browser/trust-aware-preference-reader.js.map +1 -1
- package/lib/browser/trust-aware-preference-reader.spec.js +26 -0
- package/lib/browser/trust-aware-preference-reader.spec.js.map +1 -1
- package/lib/common/ai-configuration-service.d.ts +104 -0
- package/lib/common/ai-configuration-service.d.ts.map +1 -0
- package/lib/common/ai-configuration-service.js +20 -0
- package/lib/common/ai-configuration-service.js.map +1 -0
- package/lib/common/ai-core-preferences.d.ts +4 -0
- package/lib/common/ai-core-preferences.d.ts.map +1 -1
- package/lib/common/ai-core-preferences.js +20 -1
- package/lib/common/ai-core-preferences.js.map +1 -1
- package/lib/common/index.d.ts +1 -0
- package/lib/common/index.d.ts.map +1 -1
- package/lib/common/index.js +1 -0
- package/lib/common/index.js.map +1 -1
- package/lib/common/language-model.d.ts +56 -2
- package/lib/common/language-model.d.ts.map +1 -1
- package/lib/common/language-model.js +53 -3
- package/lib/common/language-model.js.map +1 -1
- package/lib/common/language-model.spec.js +75 -0
- package/lib/common/language-model.spec.js.map +1 -1
- package/lib/common/prompt-service.d.ts +13 -0
- package/lib/common/prompt-service.d.ts.map +1 -1
- package/lib/common/prompt-service.js +8 -0
- package/lib/common/prompt-service.js.map +1 -1
- package/lib/common/prompt-service.spec.js +46 -0
- package/lib/common/prompt-service.spec.js.map +1 -1
- package/lib/node/backend-language-model-registry.d.ts.map +1 -1
- package/lib/node/backend-language-model-registry.js +1 -0
- package/lib/node/backend-language-model-registry.js.map +1 -1
- package/package.json +12 -12
- package/src/browser/ai-configuration-service-impl.spec.ts +254 -0
- package/src/browser/ai-configuration-service-impl.ts +120 -0
- package/src/browser/ai-core-frontend-module.ts +7 -1
- package/src/browser/ai-settings-service.ts +8 -16
- package/src/browser/frontend-language-model-alias-registry.ts +22 -26
- package/src/browser/frontend-language-model-service.ts +5 -6
- package/src/browser/index.ts +1 -0
- package/src/browser/prompt-variable-contribution.spec.ts +88 -2
- package/src/browser/prompt-variable-contribution.ts +13 -16
- package/src/browser/trust-aware-preference-reader.spec.ts +29 -0
- package/src/browser/trust-aware-preference-reader.ts +23 -3
- package/src/common/ai-configuration-service.ts +128 -0
- package/src/common/ai-core-preferences.ts +23 -1
- package/src/common/index.ts +1 -0
- package/src/common/language-model.spec.ts +98 -1
- package/src/common/language-model.ts +107 -4
- package/src/common/prompt-service.spec.ts +56 -0
- package/src/common/prompt-service.ts +22 -0
- package/src/node/backend-language-model-registry.ts +1 -0
|
@@ -18,7 +18,7 @@ import { nls } from '@theia/core/lib/common/nls';
|
|
|
18
18
|
import { inject, injectable } from '@theia/core/shared/inversify';
|
|
19
19
|
import { Prioritizeable } from '@theia/core/lib/common/prioritizeable';
|
|
20
20
|
import { WorkspaceTrustService } from '@theia/workspace/lib/browser/workspace-trust-service';
|
|
21
|
-
import { LanguageModel, LanguageModelResponse, ReasoningSettings, UserRequest } from '../common';
|
|
21
|
+
import { AiConfigurationService, LanguageModel, LanguageModelResponse, ReasoningSettings, UserRequest } from '../common';
|
|
22
22
|
import { LanguageModelServiceImpl } from '../common/language-model-service';
|
|
23
23
|
import {
|
|
24
24
|
PREFERENCE_NAME_REQUEST_SETTINGS,
|
|
@@ -27,7 +27,6 @@ import {
|
|
|
27
27
|
ReasoningPreferenceEntry,
|
|
28
28
|
getRequestSettingSpecificity
|
|
29
29
|
} from '../common/ai-core-preferences';
|
|
30
|
-
import { TrustAwarePreferenceReader } from './trust-aware-preference-reader';
|
|
31
30
|
|
|
32
31
|
@injectable()
|
|
33
32
|
export class FrontendLanguageModelServiceImpl extends LanguageModelServiceImpl {
|
|
@@ -35,15 +34,15 @@ export class FrontendLanguageModelServiceImpl extends LanguageModelServiceImpl {
|
|
|
35
34
|
@inject(WorkspaceTrustService)
|
|
36
35
|
protected readonly workspaceTrustService: WorkspaceTrustService;
|
|
37
36
|
|
|
38
|
-
@inject(
|
|
39
|
-
protected readonly
|
|
37
|
+
@inject(AiConfigurationService)
|
|
38
|
+
protected readonly aiConfiguration: AiConfigurationService;
|
|
40
39
|
|
|
41
40
|
override async sendRequest(
|
|
42
41
|
languageModel: LanguageModel,
|
|
43
42
|
languageModelRequest: UserRequest
|
|
44
43
|
): Promise<LanguageModelResponse> {
|
|
45
|
-
const requestSettings = this.
|
|
46
|
-
const reasoningEntries = this.
|
|
44
|
+
const requestSettings = this.aiConfiguration.get<RequestSetting[]>(PREFERENCE_NAME_REQUEST_SETTINGS, []) ?? [];
|
|
45
|
+
const reasoningEntries = this.aiConfiguration.get<ReasoningPreferenceEntry[]>(PREFERENCE_NAME_REASONING, []) ?? [];
|
|
47
46
|
const trusted = await this.workspaceTrustService.getWorkspaceTrust();
|
|
48
47
|
if (!trusted) {
|
|
49
48
|
throw new Error(nls.localize('theia/ai-core/aiDisabledInRestrictedMode', 'AI features are not available in untrusted workspaces.'));
|
package/src/browser/index.ts
CHANGED
|
@@ -23,6 +23,7 @@ export * from './ai-core-frontend-application-contribution';
|
|
|
23
23
|
export * from './ai-core-frontend-module';
|
|
24
24
|
export * from '../common/ai-core-preferences';
|
|
25
25
|
export * from './ai-settings-service';
|
|
26
|
+
export * from './ai-configuration-service-impl';
|
|
26
27
|
export * from './ai-view-contribution';
|
|
27
28
|
export * from './frontend-language-model-registry';
|
|
28
29
|
export * from './frontend-language-model-alias-registry';
|
|
@@ -225,13 +225,99 @@ describe('PromptVariableContribution', () => {
|
|
|
225
225
|
expect(result?.variable).to.deep.equal(PROMPT_VARIABLE);
|
|
226
226
|
});
|
|
227
227
|
|
|
228
|
-
|
|
228
|
+
describe('customized (file-based) commands, whose fragment id differs from the command name', () => {
|
|
229
|
+
const customized = {
|
|
230
|
+
id: 'my-file',
|
|
231
|
+
customizationId: 'customization-1',
|
|
232
|
+
priority: 1,
|
|
233
|
+
template: 'Deploy $ARGUMENTS now.',
|
|
234
|
+
isCommand: true,
|
|
235
|
+
commandName: 'deploy'
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
beforeEach(() => {
|
|
239
|
+
(promptService as unknown as Record<string, unknown>)['customizationService'] = {
|
|
240
|
+
isPromptFragmentCustomized: (id: string) => id === customized.id,
|
|
241
|
+
getActivePromptFragmentCustomization: (id: string) => (id === customized.id ? customized : undefined),
|
|
242
|
+
getCustomizedPromptFragmentIds: () => [customized.id]
|
|
243
|
+
};
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('resolves the command with arguments', async () => {
|
|
247
|
+
const result = await contribution.resolve({ variable: PROMPT_VARIABLE, arg: 'deploy|prod' }, {});
|
|
248
|
+
expect(result?.value).to.equal('Deploy prod now.');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('resolves the command without arguments', async () => {
|
|
252
|
+
const result = await contribution.resolve({ variable: PROMPT_VARIABLE, arg: 'deploy' }, {});
|
|
253
|
+
expect(result?.value).to.equal('Deploy $ARGUMENTS now.');
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('is accepted by isKnownCommand for both invocations', () => {
|
|
257
|
+
expect(promptService.isKnownCommand('deploy')).to.be.true;
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('still resolves the command by its fragment id', async () => {
|
|
261
|
+
const result = await contribution.resolve({ variable: PROMPT_VARIABLE, arg: 'my-file' }, {});
|
|
262
|
+
expect(result?.value).to.equal('Deploy $ARGUMENTS now.');
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// Resolving to `undefined` instead of an empty string makes callers keep the original text
|
|
267
|
+
// (the literal `#prompt:...` / `{{prompt:...}}` placeholder) rather than silently dropping it.
|
|
268
|
+
it('returns undefined for a non-existent prompt with arguments', async () => {
|
|
229
269
|
const result = await contribution.resolve(
|
|
230
270
|
{ variable: PROMPT_VARIABLE, arg: 'non-existent|args' },
|
|
231
271
|
{}
|
|
232
272
|
);
|
|
233
273
|
|
|
234
|
-
expect(result
|
|
274
|
+
expect(result).to.be.undefined;
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('returns undefined for a non-existent prompt without arguments', async () => {
|
|
278
|
+
const result = await contribution.resolve(
|
|
279
|
+
{ variable: PROMPT_VARIABLE, arg: 'non-existent' },
|
|
280
|
+
{}
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
expect(result).to.be.undefined;
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('returns undefined for a path-like argument', async () => {
|
|
287
|
+
promptService.addBuiltInPromptFragment({
|
|
288
|
+
id: 'hello-cmd',
|
|
289
|
+
template: 'Hello, world!',
|
|
290
|
+
isCommand: true,
|
|
291
|
+
commandName: 'hello'
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const result = await contribution.resolve(
|
|
295
|
+
{ variable: PROMPT_VARIABLE, arg: 'home|/user/notes.txt and fix the bug' },
|
|
296
|
+
{}
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
expect(result).to.be.undefined;
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('returns undefined when no argument is given', async () => {
|
|
303
|
+
expect(await contribution.resolve({ variable: PROMPT_VARIABLE, arg: undefined }, {})).to.be.undefined;
|
|
304
|
+
expect(await contribution.resolve({ variable: PROMPT_VARIABLE, arg: ' ' }, {})).to.be.undefined;
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('resolves a command referenced by its fragment id without arguments', async () => {
|
|
308
|
+
promptService.addBuiltInPromptFragment({
|
|
309
|
+
id: 'hello-cmd',
|
|
310
|
+
template: 'Hello, world!',
|
|
311
|
+
isCommand: true,
|
|
312
|
+
commandName: 'hello'
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const result = await contribution.resolve(
|
|
316
|
+
{ variable: PROMPT_VARIABLE, arg: 'hello' },
|
|
317
|
+
{}
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
expect(result?.value).to.equal('Hello, world!');
|
|
235
321
|
});
|
|
236
322
|
});
|
|
237
323
|
});
|
|
@@ -76,11 +76,13 @@ export class PromptVariableContribution implements AIVariableContribution, AIVar
|
|
|
76
76
|
const promptIdOrCommandName = pipeIndex >= 0 ? arg.substring(0, pipeIndex) : arg;
|
|
77
77
|
const commandArgs = pipeIndex >= 0 ? arg.substring(pipeIndex + 1) : '';
|
|
78
78
|
|
|
79
|
-
// Determine the actual fragment ID
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
79
|
+
// Determine the actual fragment ID by looking up the command name first.
|
|
80
|
+
// This must not be conditional on arguments being present: a customized (file-based)
|
|
81
|
+
// command has a fragment id that differs from its command name, so `/deploy` without
|
|
82
|
+
// arguments would otherwise not resolve even though `/deploy prod` does. It also keeps
|
|
83
|
+
// this lookup aligned with `PromptService.isKnownCommand`, which the chat request
|
|
84
|
+
// parser uses to decide whether `/deploy` is a command at all.
|
|
85
|
+
let fragment = this.promptService.getPromptFragmentByCommandName(promptIdOrCommandName);
|
|
84
86
|
|
|
85
87
|
// Fall back to looking up by fragment ID if not found by command name
|
|
86
88
|
if (!fragment) {
|
|
@@ -90,11 +92,7 @@ export class PromptVariableContribution implements AIVariableContribution, AIVar
|
|
|
90
92
|
// If we still don't have a fragment, we can't resolve
|
|
91
93
|
if (!fragment) {
|
|
92
94
|
this.logger.debug(`Could not find prompt fragment or command '${promptIdOrCommandName}'`);
|
|
93
|
-
return
|
|
94
|
-
variable: request.variable,
|
|
95
|
-
value: '',
|
|
96
|
-
allResolvedDependencies: []
|
|
97
|
-
};
|
|
95
|
+
return undefined;
|
|
98
96
|
}
|
|
99
97
|
|
|
100
98
|
const fragmentId = fragment.id;
|
|
@@ -124,12 +122,11 @@ export class PromptVariableContribution implements AIVariableContribution, AIVar
|
|
|
124
122
|
}
|
|
125
123
|
}
|
|
126
124
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
};
|
|
125
|
+
// Returning `undefined` rather than an empty resolution keeps the original text in place:
|
|
126
|
+
// callers fall back to the literal `#prompt:...` / `{{prompt:...}}` placeholder instead of
|
|
127
|
+
// silently dropping it, which is also how every other unresolvable variable behaves.
|
|
128
|
+
this.logger.debug(`Could not resolve prompt variable '${request.variable.name}' with arg '${request.arg}'.`);
|
|
129
|
+
return undefined;
|
|
133
130
|
}
|
|
134
131
|
|
|
135
132
|
private substituteCommandArguments(template: string, commandName: string, commandArgs: string): string {
|
|
@@ -165,6 +165,35 @@ describe('TrustAwarePreferenceReader', () => {
|
|
|
165
165
|
});
|
|
166
166
|
});
|
|
167
167
|
|
|
168
|
+
describe('suppressUntrusted', () => {
|
|
169
|
+
const inspection = {
|
|
170
|
+
preferenceName: PREFERENCE_NAME,
|
|
171
|
+
defaultValue: 'default',
|
|
172
|
+
globalValue: 'user',
|
|
173
|
+
workspaceValue: 'workspace',
|
|
174
|
+
workspaceFolderValue: 'folder',
|
|
175
|
+
value: 'folder'
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
it('clears the workspace and folder values when untrusted', async () => {
|
|
179
|
+
trust.trustDeferred.resolve(false);
|
|
180
|
+
await reader.ready;
|
|
181
|
+
const result = reader.suppressUntrusted({ ...inspection });
|
|
182
|
+
expect(result.workspaceValue).to.equal(undefined);
|
|
183
|
+
expect(result.workspaceFolderValue).to.equal(undefined);
|
|
184
|
+
expect(result.globalValue).to.equal('user');
|
|
185
|
+
expect(result.defaultValue).to.equal('default');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('returns the inspection unchanged when trusted', async () => {
|
|
189
|
+
trust.trustDeferred.resolve(true);
|
|
190
|
+
await reader.ready;
|
|
191
|
+
const result = reader.suppressUntrusted({ ...inspection });
|
|
192
|
+
expect(result.workspaceValue).to.equal('workspace');
|
|
193
|
+
expect(result.workspaceFolderValue).to.equal('folder');
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
168
197
|
describe('onDidChangeTrust', () => {
|
|
169
198
|
beforeEach(async () => {
|
|
170
199
|
trust.trustDeferred.resolve(false);
|
|
@@ -18,7 +18,7 @@ import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'
|
|
|
18
18
|
import { JSONValue } from '@theia/core/shared/@lumino/coreutils';
|
|
19
19
|
import { Emitter, Event } from '@theia/core';
|
|
20
20
|
import { Deferred } from '@theia/core/lib/common/promise-util';
|
|
21
|
-
import { PreferenceService } from '@theia/core/lib/common/preferences';
|
|
21
|
+
import { PreferenceInspection, PreferenceService } from '@theia/core/lib/common/preferences';
|
|
22
22
|
import { WorkspaceTrustService } from '@theia/workspace/lib/browser/workspace-trust-service';
|
|
23
23
|
|
|
24
24
|
/**
|
|
@@ -27,6 +27,9 @@ import { WorkspaceTrustService } from '@theia/workspace/lib/browser/workspace-tr
|
|
|
27
27
|
*
|
|
28
28
|
* Writes should continue to go through `PreferenceService` directly; trust only
|
|
29
29
|
* affects reads.
|
|
30
|
+
*
|
|
31
|
+
* @internal This is an implementation detail of {@link AiConfigurationService}. AI configuration
|
|
32
|
+
* consumers should go through `AiConfigurationService` rather than injecting this reader directly.
|
|
30
33
|
*/
|
|
31
34
|
@injectable()
|
|
32
35
|
export class TrustAwarePreferenceReader {
|
|
@@ -102,7 +105,24 @@ export class TrustAwarePreferenceReader {
|
|
|
102
105
|
return this.preferences.get<T>(preferenceName, fallback, resourceUri);
|
|
103
106
|
}
|
|
104
107
|
const inspection = this.preferences.inspect<JSONValue>(preferenceName, resourceUri);
|
|
105
|
-
|
|
106
|
-
|
|
108
|
+
if (!inspection) {
|
|
109
|
+
return fallback;
|
|
110
|
+
}
|
|
111
|
+
const suppressed = this.suppressUntrusted(inspection);
|
|
112
|
+
return ((suppressed.globalValue ?? suppressed.defaultValue) as T | undefined) ?? fallback;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Narrows a raw {@link PreferenceInspection} according to workspace trust: when the workspace is
|
|
117
|
+
* untrusted the folder and workspace scope values are dropped so that only the user and default
|
|
118
|
+
* scopes contribute (failing closed until the trust state resolves). A trusted inspection is
|
|
119
|
+
* returned unchanged. This is the single definition of the trust-suppression rule, shared by
|
|
120
|
+
* {@link get} and by inspection-based consumers, so value reads and inspections cannot drift.
|
|
121
|
+
*/
|
|
122
|
+
suppressUntrusted<T extends JSONValue>(inspection: PreferenceInspection<T>): PreferenceInspection<T> {
|
|
123
|
+
if (this.trusted) {
|
|
124
|
+
return inspection;
|
|
125
|
+
}
|
|
126
|
+
return { ...inspection, workspaceValue: undefined, workspaceFolderValue: undefined };
|
|
107
127
|
}
|
|
108
128
|
}
|
|
@@ -0,0 +1,128 @@
|
|
|
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 { Event } from '@theia/core';
|
|
18
|
+
import { JSONValue } from '@theia/core/shared/@lumino/coreutils';
|
|
19
|
+
import { PreferenceInspection, PreferenceScope } from '@theia/core/lib/common/preferences';
|
|
20
|
+
|
|
21
|
+
export const AiConfigurationService = Symbol('AiConfigurationService');
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A {@link PreferenceInspection} augmented with a derived, workspace-trust-aware source scope.
|
|
25
|
+
*/
|
|
26
|
+
export interface AiConfigurationInspection<T extends JSONValue = JSONValue> extends PreferenceInspection<T> {
|
|
27
|
+
/**
|
|
28
|
+
* The narrowest scope (honoring workspace trust) in which a value is explicitly set, i.e. the
|
|
29
|
+
* scope that determines the effective {@link PreferenceInspection.value}. Walked in precedence
|
|
30
|
+
* order `Folder -> Workspace -> User`; `undefined` when only the default value applies. In an
|
|
31
|
+
* untrusted workspace the folder/workspace scopes are ignored, so `sourceScope` is either
|
|
32
|
+
* `User` or `undefined`.
|
|
33
|
+
*/
|
|
34
|
+
sourceScope?: PreferenceScope;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Payload of {@link AiConfigurationService.onDidChange}.
|
|
39
|
+
*/
|
|
40
|
+
export interface AiConfigurationChange {
|
|
41
|
+
/**
|
|
42
|
+
* The preference key that changed. When the change is caused by a workspace-trust transition
|
|
43
|
+
* (which can change the effective value of any trust-gated key) this is `undefined` and
|
|
44
|
+
* {@link affects} returns `true` for every resource. Listeners tracking a specific key should
|
|
45
|
+
* re-query the value when `preferenceName` is `undefined` or equals their key.
|
|
46
|
+
*/
|
|
47
|
+
readonly preferenceName?: string;
|
|
48
|
+
/**
|
|
49
|
+
* Tests whether the given resource is affected by the change.
|
|
50
|
+
* @param resourceUri the uri of the resource to test.
|
|
51
|
+
*/
|
|
52
|
+
affects(resourceUri?: string): boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Tests whether the given preference key is affected by this change, treating a workspace-trust
|
|
55
|
+
* transition (where {@link preferenceName} is `undefined`) as affecting every trust-gated key.
|
|
56
|
+
* Prefer this over comparing {@link preferenceName} directly so listeners do not silently miss
|
|
57
|
+
* trust transitions.
|
|
58
|
+
* @param preferenceName the preference key to test.
|
|
59
|
+
*/
|
|
60
|
+
affectsPreference(preferenceName: string): boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Framework API for reading and writing AI-related (`ai-features.*`) preferences.
|
|
65
|
+
*
|
|
66
|
+
* `AiConfigurationService` wraps the core `PreferenceService` and is the intended extension point
|
|
67
|
+
* for adopters and extensions that configure AI features: prefer it over talking to
|
|
68
|
+
* `PreferenceService` directly for `ai-features.*` keys. Routing all AI configuration through this
|
|
69
|
+
* seam keeps consumers insulated from future changes to how AI preferences are stored.
|
|
70
|
+
*
|
|
71
|
+
* Reads ({@link get}, {@link inspect}) are **workspace-trust-aware**: workspace and folder scope
|
|
72
|
+
* values are suppressed while the workspace is untrusted (failing closed until the trust state
|
|
73
|
+
* resolves), matching how AI features read preferences today. Writes ({@link set}, {@link update})
|
|
74
|
+
* are never gated by trust.
|
|
75
|
+
*/
|
|
76
|
+
export interface AiConfigurationService {
|
|
77
|
+
/**
|
|
78
|
+
* Resolves once the underlying preference service and the initial workspace-trust state are
|
|
79
|
+
* ready. Await this before the first read for a deterministic, trust-aware result.
|
|
80
|
+
*/
|
|
81
|
+
readonly ready: Promise<void>;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Retrieves the effective, trust-aware value for the given preference.
|
|
85
|
+
*
|
|
86
|
+
* @param key the preference identifier.
|
|
87
|
+
* @param defaultValue the value to return when no value is stored.
|
|
88
|
+
* @param resourceUri the uri of the resource for which the preference is read.
|
|
89
|
+
*/
|
|
90
|
+
get<T>(key: string, defaultValue?: T, resourceUri?: string): T | undefined;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Writes `value` to the given scope. Maps to `PreferenceService.set`.
|
|
94
|
+
*
|
|
95
|
+
* @param key the preference identifier.
|
|
96
|
+
* @param value the new value (must be JSON-serializable). `undefined` clears the value in the given scope.
|
|
97
|
+
* @param scope the scope to write to. For {@link PreferenceScope.Folder} a `resourceUri` is required.
|
|
98
|
+
* @param resourceUri the uri of the resource for which the preference is stored.
|
|
99
|
+
*/
|
|
100
|
+
set(key: string, value: unknown, scope: PreferenceScope, resourceUri?: string): Promise<void>;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* "Smart write": picks the scope so that the effective value becomes `value`. Maps to
|
|
104
|
+
* `PreferenceService.updateValue`.
|
|
105
|
+
*
|
|
106
|
+
* @param key the preference identifier.
|
|
107
|
+
* @param value the value to apply (must be JSON-serializable). `undefined` resets the preference to its default value.
|
|
108
|
+
* @param resourceUri the uri of the resource to which the change applies.
|
|
109
|
+
*/
|
|
110
|
+
update(key: string, value: unknown, resourceUri?: string): Promise<void>;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Retrieves the per-scope values for the given preference, enriched with a derived,
|
|
114
|
+
* trust-aware {@link AiConfigurationInspection.sourceScope} and effective
|
|
115
|
+
* {@link PreferenceInspection.value}.
|
|
116
|
+
*
|
|
117
|
+
* @param key the preference identifier.
|
|
118
|
+
* @param resourceUri the uri of the resource for which the preference is inspected.
|
|
119
|
+
*/
|
|
120
|
+
inspect<T extends JSONValue>(key: string, resourceUri?: string): AiConfigurationInspection<T> | undefined;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Fires when a `ai-features.*` preference changes or when the workspace-trust state transitions
|
|
124
|
+
* (which can change the effective value of trust-gated keys). The effective value is not carried
|
|
125
|
+
* on the event; listeners re-query it via {@link get}/{@link inspect}.
|
|
126
|
+
*/
|
|
127
|
+
onDidChange: Event<AiConfigurationChange>;
|
|
128
|
+
}
|
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
NOTIFICATION_TYPE_DESCRIPTIONS,
|
|
25
25
|
NotificationType
|
|
26
26
|
} from './notification-types';
|
|
27
|
-
import { ReasoningSettings } from './language-model';
|
|
27
|
+
import { ReasoningSettings, SERVER_SIDE_COMPACTION_TOKEN_THRESHOLD_MINIMUM } from './language-model';
|
|
28
28
|
import { PreferenceSchema } from '@theia/core/lib/common/preferences/preference-schema';
|
|
29
29
|
|
|
30
30
|
export const AI_CORE_PREFERENCES_TITLE = nls.localize('theia/ai-core/preferences/title', 'AI Features');
|
|
@@ -34,11 +34,31 @@ export const PREFERENCE_NAME_REASONING = 'ai-features.reasoning.defaults';
|
|
|
34
34
|
export const PREFERENCE_NAME_MAX_RETRIES = 'ai-features.modelSettings.maxRetries';
|
|
35
35
|
export const PREFERENCE_NAME_DEFAULT_NOTIFICATION_TYPE = 'ai-features.notifications.default';
|
|
36
36
|
export const PREFERENCE_NAME_SKILL_DIRECTORIES = 'ai-features.skills.skillDirectories';
|
|
37
|
+
export const PREFERENCE_NAME_SERVER_SIDE_COMPACTION = 'ai-features.chat.serverSideCompaction';
|
|
38
|
+
export const PREFERENCE_NAME_SERVER_SIDE_COMPACTION_TOKEN_THRESHOLD = 'ai-features.chat.serverSideCompactionTokenThreshold';
|
|
37
39
|
|
|
38
40
|
export const LANGUAGE_MODEL_ALIASES_PREFERENCE = 'ai-features.languageModelAliases';
|
|
39
41
|
|
|
40
42
|
export const aiCorePreferenceSchema: PreferenceSchema = {
|
|
41
43
|
properties: {
|
|
44
|
+
[PREFERENCE_NAME_SERVER_SIDE_COMPACTION]: {
|
|
45
|
+
title: AI_CORE_PREFERENCES_TITLE,
|
|
46
|
+
type: 'boolean',
|
|
47
|
+
description: nls.localize('theia/ai/core/serverSideCompaction/description',
|
|
48
|
+
'Enable provider-native server-side compaction by default for chat requests. When on, models that support it ' +
|
|
49
|
+
'may automatically summarize older turns once the conversation grows past their own threshold, letting long ' +
|
|
50
|
+
'sessions continue past the context limit. Each provider offers its own override, and individual chat sessions ' +
|
|
51
|
+
'can override this in their session settings. Models without support ignore it.'),
|
|
52
|
+
default: true,
|
|
53
|
+
},
|
|
54
|
+
[PREFERENCE_NAME_SERVER_SIDE_COMPACTION_TOKEN_THRESHOLD]: {
|
|
55
|
+
title: AI_CORE_PREFERENCES_TITLE,
|
|
56
|
+
type: 'integer',
|
|
57
|
+
minimum: SERVER_SIDE_COMPACTION_TOKEN_THRESHOLD_MINIMUM,
|
|
58
|
+
description: nls.localize('theia/ai/core/serverSideCompactionTokenThreshold/description',
|
|
59
|
+
'Default input-token threshold at which provider-native server-side compaction should run. Providers and individual chat sessions ' +
|
|
60
|
+
'can override this value. When unset, the provider chooses its default threshold. If set, the value must be at least 50,000 tokens.'),
|
|
61
|
+
},
|
|
42
62
|
[PREFERENCE_NAME_PROMPT_TEMPLATES]: {
|
|
43
63
|
title: AI_CORE_PREFERENCES_TITLE,
|
|
44
64
|
description: nls.localize('theia/ai/core/promptTemplates/description',
|
|
@@ -239,6 +259,8 @@ export interface AICoreConfiguration {
|
|
|
239
259
|
[PREFERENCE_NAME_MAX_RETRIES]: number | undefined;
|
|
240
260
|
[PREFERENCE_NAME_DEFAULT_NOTIFICATION_TYPE]: NotificationType | undefined;
|
|
241
261
|
[PREFERENCE_NAME_SKILL_DIRECTORIES]: string[] | undefined;
|
|
262
|
+
[PREFERENCE_NAME_SERVER_SIDE_COMPACTION]: boolean | undefined;
|
|
263
|
+
[PREFERENCE_NAME_SERVER_SIDE_COMPACTION_TOKEN_THRESHOLD]: number | undefined;
|
|
242
264
|
}
|
|
243
265
|
|
|
244
266
|
export interface RequestSetting {
|
package/src/common/index.ts
CHANGED
|
@@ -31,6 +31,7 @@ export * from './protocol';
|
|
|
31
31
|
export * from './today-variable-contribution';
|
|
32
32
|
export * from './variable-service';
|
|
33
33
|
export * from './settings-service';
|
|
34
|
+
export * from './ai-configuration-service';
|
|
34
35
|
export * from './language-model-service';
|
|
35
36
|
export * from './token-usage-service';
|
|
36
37
|
export * from './ai-variable-resource';
|
|
@@ -15,7 +15,19 @@
|
|
|
15
15
|
// *****************************************************************************
|
|
16
16
|
|
|
17
17
|
import { expect } from 'chai';
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
DefaultLanguageModelRegistryImpl,
|
|
20
|
+
isCompactionResponsePart,
|
|
21
|
+
isLanguageModelStreamResponsePart,
|
|
22
|
+
isModelMatching,
|
|
23
|
+
isToolCallContent,
|
|
24
|
+
LanguageModel,
|
|
25
|
+
LanguageModelSelector,
|
|
26
|
+
resolveCompactionDefault,
|
|
27
|
+
resolveCompactionTokenThreshold,
|
|
28
|
+
resolveCompactionTokenThresholdDefault,
|
|
29
|
+
resolveServerSideCompaction
|
|
30
|
+
} from './language-model';
|
|
19
31
|
|
|
20
32
|
describe('isModelMatching', () => {
|
|
21
33
|
it('returns false with one of two parameter mismatches', () => {
|
|
@@ -163,3 +175,88 @@ describe('isToolCallContent', () => {
|
|
|
163
175
|
});
|
|
164
176
|
|
|
165
177
|
});
|
|
178
|
+
|
|
179
|
+
describe('DefaultLanguageModelRegistryImpl', () => {
|
|
180
|
+
function createRegistry(): DefaultLanguageModelRegistryImpl {
|
|
181
|
+
const registry = new DefaultLanguageModelRegistryImpl();
|
|
182
|
+
// No DI container in this unit test, so resolve the internal `initialized` gate manually.
|
|
183
|
+
(registry as unknown as { markInitialized: () => void }).markInitialized();
|
|
184
|
+
return registry;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function model(id: string): LanguageModel {
|
|
188
|
+
return { id, status: { status: 'ready' } } as LanguageModel;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
it('getLanguageModels returns a fresh array so consumers can detect changes by reference', async () => {
|
|
192
|
+
const registry = createRegistry();
|
|
193
|
+
registry.addLanguageModels([model('a')]);
|
|
194
|
+
|
|
195
|
+
const first = await registry.getLanguageModels();
|
|
196
|
+
expect(first).to.have.length(1);
|
|
197
|
+
|
|
198
|
+
registry.addLanguageModels([model('b')]);
|
|
199
|
+
const second = await registry.getLanguageModels();
|
|
200
|
+
|
|
201
|
+
// A new snapshot must be a different array reference (React memoization relies on this).
|
|
202
|
+
expect(second).to.not.equal(first);
|
|
203
|
+
// The earlier snapshot must not be mutated in place by later registry changes.
|
|
204
|
+
expect(first.map(m => m.id)).to.deep.equal(['a']);
|
|
205
|
+
expect(second.map(m => m.id)).to.deep.equal(['a', 'b']);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('reflects removals in a fresh snapshot without mutating an earlier one', async () => {
|
|
209
|
+
const registry = createRegistry();
|
|
210
|
+
registry.addLanguageModels([model('a'), model('b')]);
|
|
211
|
+
const before = await registry.getLanguageModels();
|
|
212
|
+
|
|
213
|
+
registry.removeLanguageModels(['a']);
|
|
214
|
+
const after = await registry.getLanguageModels();
|
|
215
|
+
|
|
216
|
+
expect(before.map(m => m.id)).to.deep.equal(['a', 'b']);
|
|
217
|
+
expect(after.map(m => m.id)).to.deep.equal(['b']);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe('compaction contract', () => {
|
|
222
|
+
it('recognizes a compaction response part', () => {
|
|
223
|
+
const part = { compaction: { provider: 'anthropic', data: { foo: 1 } } };
|
|
224
|
+
expect(isCompactionResponsePart(part)).to.equal(true);
|
|
225
|
+
expect(isLanguageModelStreamResponsePart(part)).to.equal(true);
|
|
226
|
+
});
|
|
227
|
+
it('rejects a non-compaction part', () => {
|
|
228
|
+
expect(isCompactionResponsePart({ content: 'hi' })).to.equal(false);
|
|
229
|
+
// eslint-disable-next-line no-null/no-null
|
|
230
|
+
expect(isCompactionResponsePart(null)).to.equal(false);
|
|
231
|
+
expect(isCompactionResponsePart(undefined)).to.equal(false);
|
|
232
|
+
// eslint-disable-next-line no-null/no-null
|
|
233
|
+
expect(isCompactionResponsePart({ compaction: null })).to.equal(false);
|
|
234
|
+
expect(isCompactionResponsePart({ compaction: { provider: 42 } })).to.equal(false);
|
|
235
|
+
});
|
|
236
|
+
it('resolves a model default from the global preference and the per-provider override', () => {
|
|
237
|
+
expect(resolveCompactionDefault(true, 'default')).to.equal(true);
|
|
238
|
+
expect(resolveCompactionDefault(false, 'default')).to.equal(false);
|
|
239
|
+
expect(resolveCompactionDefault(false, 'enabled')).to.equal(true);
|
|
240
|
+
expect(resolveCompactionDefault(true, 'disabled')).to.equal(false);
|
|
241
|
+
});
|
|
242
|
+
it('resolves compaction token thresholds with session, provider, global precedence', () => {
|
|
243
|
+
expect(resolveCompactionTokenThresholdDefault(undefined, undefined)).to.equal(undefined);
|
|
244
|
+
expect(resolveCompactionTokenThresholdDefault(100_000, undefined)).to.equal(100_000);
|
|
245
|
+
expect(resolveCompactionTokenThresholdDefault(100_000, 200_000)).to.equal(200_000);
|
|
246
|
+
expect(resolveCompactionTokenThreshold(undefined, undefined)).to.equal(undefined);
|
|
247
|
+
expect(resolveCompactionTokenThreshold(200_000, undefined)).to.equal(200_000);
|
|
248
|
+
expect(resolveCompactionTokenThreshold(200_000, { tokenThreshold: 300_000 })).to.equal(300_000);
|
|
249
|
+
});
|
|
250
|
+
it('resolves server-side compaction with capability gate and session-wins precedence', () => {
|
|
251
|
+
// capability gate
|
|
252
|
+
expect(resolveServerSideCompaction(false, true, { enabled: true })).to.equal(false);
|
|
253
|
+
expect(resolveServerSideCompaction(undefined, true, undefined)).to.equal(false);
|
|
254
|
+
// no per-session setting -> model default
|
|
255
|
+
expect(resolveServerSideCompaction(true, true, undefined)).to.equal(true);
|
|
256
|
+
expect(resolveServerSideCompaction(true, false, undefined)).to.equal(false);
|
|
257
|
+
expect(resolveServerSideCompaction(true, true, {})).to.equal(true);
|
|
258
|
+
// explicit per-session setting wins over the model default (which already folds in the per-provider override)
|
|
259
|
+
expect(resolveServerSideCompaction(true, false, { enabled: true })).to.equal(true);
|
|
260
|
+
expect(resolveServerSideCompaction(true, true, { enabled: false })).to.equal(false);
|
|
261
|
+
});
|
|
262
|
+
});
|