@theia/ai-core 1.76.0-next.6 → 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/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/discovering-provider-contribution.d.ts +92 -0
- package/lib/browser/discovering-provider-contribution.d.ts.map +1 -0
- package/lib/browser/discovering-provider-contribution.js +189 -0
- package/lib/browser/discovering-provider-contribution.js.map +1 -0
- package/lib/browser/discovering-provider-contribution.spec.d.ts +2 -0
- package/lib/browser/discovering-provider-contribution.spec.d.ts.map +1 -0
- package/lib/browser/discovering-provider-contribution.spec.js +233 -0
- package/lib/browser/discovering-provider-contribution.spec.js.map +1 -0
- package/lib/browser/favorite-models-service.d.ts +135 -0
- package/lib/browser/favorite-models-service.d.ts.map +1 -0
- package/lib/browser/favorite-models-service.js +279 -0
- package/lib/browser/favorite-models-service.js.map +1 -0
- package/lib/browser/favorite-models-service.spec.d.ts +2 -0
- package/lib/browser/favorite-models-service.spec.d.ts.map +1 -0
- package/lib/browser/favorite-models-service.spec.js +277 -0
- package/lib/browser/favorite-models-service.spec.js.map +1 -0
- package/lib/browser/frontend-language-model-alias-registry.js +1 -1
- package/lib/browser/frontend-language-model-service.d.ts.map +1 -1
- package/lib/browser/frontend-language-model-service.js +8 -0
- package/lib/browser/frontend-language-model-service.js.map +1 -1
- package/lib/browser/frontend-language-model-service.spec.d.ts +2 -0
- package/lib/browser/frontend-language-model-service.spec.d.ts.map +1 -0
- package/lib/browser/frontend-language-model-service.spec.js +76 -0
- package/lib/browser/frontend-language-model-service.spec.js.map +1 -0
- package/lib/browser/index.d.ts +3 -0
- package/lib/browser/index.d.ts.map +1 -1
- package/lib/browser/index.js +3 -0
- package/lib/browser/index.js.map +1 -1
- package/lib/browser/model-discovery-status-service.d.ts +52 -0
- package/lib/browser/model-discovery-status-service.d.ts.map +1 -0
- package/lib/browser/model-discovery-status-service.js +110 -0
- package/lib/browser/model-discovery-status-service.js.map +1 -0
- package/lib/browser/model-discovery-status-service.spec.d.ts +2 -0
- package/lib/browser/model-discovery-status-service.spec.d.ts.map +1 -0
- package/lib/browser/model-discovery-status-service.spec.js +86 -0
- package/lib/browser/model-discovery-status-service.spec.js.map +1 -0
- package/lib/common/ai-core-preferences.d.ts +11 -0
- package/lib/common/ai-core-preferences.d.ts.map +1 -1
- package/lib/common/ai-core-preferences.js +37 -1
- package/lib/common/ai-core-preferences.js.map +1 -1
- package/lib/common/index.d.ts +2 -0
- package/lib/common/index.d.ts.map +1 -1
- package/lib/common/index.js +2 -0
- package/lib/common/index.js.map +1 -1
- package/lib/common/language-model-ordering.spec.d.ts +2 -0
- package/lib/common/language-model-ordering.spec.d.ts.map +1 -0
- package/lib/common/language-model-ordering.spec.js +72 -0
- package/lib/common/language-model-ordering.spec.js.map +1 -0
- package/lib/common/language-model-util.d.ts +17 -1
- package/lib/common/language-model-util.d.ts.map +1 -1
- package/lib/common/language-model-util.js +44 -1
- package/lib/common/language-model-util.js.map +1 -1
- package/lib/common/language-model.d.ts +21 -0
- package/lib/common/language-model.d.ts.map +1 -1
- package/lib/common/language-model.js +50 -1
- package/lib/common/language-model.js.map +1 -1
- package/lib/common/language-model.spec.js +36 -0
- package/lib/common/language-model.spec.js.map +1 -1
- package/lib/common/model-discovery-status.d.ts +112 -0
- package/lib/common/model-discovery-status.d.ts.map +1 -0
- package/lib/common/model-discovery-status.js +18 -0
- package/lib/common/model-discovery-status.js.map +1 -0
- package/lib/common/model-discovery-util.d.ts +46 -0
- package/lib/common/model-discovery-util.d.ts.map +1 -0
- package/lib/common/model-discovery-util.js +128 -0
- package/lib/common/model-discovery-util.js.map +1 -0
- package/lib/common/model-discovery-util.spec.d.ts +2 -0
- package/lib/common/model-discovery-util.spec.d.ts.map +1 -0
- package/lib/common/model-discovery-util.spec.js +95 -0
- package/lib/common/model-discovery-util.spec.js.map +1 -0
- package/lib/common/prompt-service.js +3 -3
- package/lib/common/prompt-service.js.map +1 -1
- package/lib/common/prompt-service.spec.js +5 -0
- package/lib/common/prompt-service.spec.js.map +1 -1
- package/lib/node/ai-core-backend-module.d.ts.map +1 -1
- package/lib/node/ai-core-backend-module.js +8 -0
- package/lib/node/ai-core-backend-module.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/lib/node/index.d.ts +2 -0
- package/lib/node/index.d.ts.map +1 -1
- package/lib/node/index.js +2 -0
- package/lib/node/index.js.map +1 -1
- package/lib/node/model-discovery-fetcher.d.ts +43 -0
- package/lib/node/model-discovery-fetcher.d.ts.map +1 -0
- package/lib/node/model-discovery-fetcher.js +85 -0
- package/lib/node/model-discovery-fetcher.js.map +1 -0
- package/lib/node/model-snapshot-store.d.ts +26 -0
- package/lib/node/model-snapshot-store.d.ts.map +1 -0
- package/lib/node/model-snapshot-store.js +73 -0
- package/lib/node/model-snapshot-store.js.map +1 -0
- package/lib/node/test/test-model-discovery-fetcher.d.ts +21 -0
- package/lib/node/test/test-model-discovery-fetcher.d.ts.map +1 -0
- package/lib/node/test/test-model-discovery-fetcher.js +54 -0
- package/lib/node/test/test-model-discovery-fetcher.js.map +1 -0
- package/package.json +12 -12
- package/src/browser/ai-core-frontend-module.ts +5 -0
- package/src/browser/discovering-provider-contribution.spec.ts +304 -0
- package/src/browser/discovering-provider-contribution.ts +241 -0
- package/src/browser/favorite-models-service.spec.ts +336 -0
- package/src/browser/favorite-models-service.ts +297 -0
- package/src/browser/frontend-language-model-alias-registry.ts +1 -1
- package/src/browser/frontend-language-model-service.spec.ts +86 -0
- package/src/browser/frontend-language-model-service.ts +9 -1
- package/src/browser/index.ts +3 -0
- package/src/browser/model-discovery-status-service.spec.ts +101 -0
- package/src/browser/model-discovery-status-service.ts +123 -0
- package/src/common/ai-core-preferences.ts +40 -0
- package/src/common/index.ts +2 -0
- package/src/common/language-model-ordering.spec.ts +83 -0
- package/src/common/language-model-util.ts +43 -0
- package/src/common/language-model.spec.ts +45 -0
- package/src/common/language-model.ts +54 -0
- package/src/common/model-discovery-status.ts +132 -0
- package/src/common/model-discovery-util.spec.ts +105 -0
- package/src/common/model-discovery-util.ts +129 -0
- package/src/common/prompt-service.spec.ts +6 -0
- package/src/common/prompt-service.ts +3 -3
- package/src/node/ai-core-backend-module.ts +8 -0
- package/src/node/backend-language-model-registry.ts +1 -0
- package/src/node/index.ts +2 -0
- package/src/node/model-discovery-fetcher.ts +105 -0
- package/src/node/model-snapshot-store.ts +80 -0
- package/src/node/test/test-model-discovery-fetcher.ts +62 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// *****************************************************************************
|
|
2
|
+
// Copyright (C) 2026 Safi Seid-Ahmad, K2view and others.
|
|
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 { enableJSDOM } from '@theia/core/lib/browser/test/jsdom';
|
|
18
|
+
let disableJSDOM = enableJSDOM();
|
|
19
|
+
import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider';
|
|
20
|
+
FrontendApplicationConfigProvider.set({});
|
|
21
|
+
|
|
22
|
+
import { expect } from 'chai';
|
|
23
|
+
import { LanguageModel, ReasoningSettings, ReasoningSupport, UserRequest } from '../common';
|
|
24
|
+
import { PREFERENCE_NAME_REASONING, ReasoningPreferenceEntry } from '../common/ai-core-preferences';
|
|
25
|
+
import { FrontendLanguageModelServiceImpl } from './frontend-language-model-service';
|
|
26
|
+
|
|
27
|
+
disableJSDOM();
|
|
28
|
+
|
|
29
|
+
const O_SERIES: ReasoningSupport = { supportedLevels: ['off', 'low', 'medium', 'high', 'auto'], defaultLevel: 'auto' };
|
|
30
|
+
const GPT5: ReasoningSupport = { supportedLevels: ['off', 'minimal', 'low', 'medium', 'high', 'auto'], defaultLevel: 'auto' };
|
|
31
|
+
|
|
32
|
+
/** Service with the trust check and the reasoning preference entries stubbed; nothing else in `sendRequest` needs a container. */
|
|
33
|
+
function createService(reasoningEntries: ReasoningPreferenceEntry[] = []): FrontendLanguageModelServiceImpl {
|
|
34
|
+
const service = new FrontendLanguageModelServiceImpl();
|
|
35
|
+
Object.assign(service, {
|
|
36
|
+
workspaceTrustService: { getWorkspaceTrust: async () => true },
|
|
37
|
+
aiConfiguration: { get: <T>(key: string, defaultValue?: T): T | undefined => key === PREFERENCE_NAME_REASONING ? reasoningEntries as unknown as T : defaultValue }
|
|
38
|
+
});
|
|
39
|
+
return service;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function createModel(id: string, reasoningSupport: ReasoningSupport | undefined, captured: UserRequest[]): LanguageModel {
|
|
43
|
+
return {
|
|
44
|
+
id,
|
|
45
|
+
reasoningSupport,
|
|
46
|
+
status: { status: 'ready' },
|
|
47
|
+
request: async (request: UserRequest) => {
|
|
48
|
+
captured.push(request);
|
|
49
|
+
return { text: '' };
|
|
50
|
+
}
|
|
51
|
+
} as unknown as LanguageModel;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function createRequest(reasoning?: ReasoningSettings): UserRequest {
|
|
55
|
+
return { messages: [{ actor: 'user', type: 'text', text: 'hello' }], agentId: 'agent', sessionId: 'session', requestId: 'request', reasoning };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('FrontendLanguageModelServiceImpl reasoning level', () => {
|
|
59
|
+
before(() => disableJSDOM = enableJSDOM());
|
|
60
|
+
after(() => disableJSDOM());
|
|
61
|
+
|
|
62
|
+
it('clamps a session-selected level the model does not support to the nearest supported one', async () => {
|
|
63
|
+
const captured: UserRequest[] = [];
|
|
64
|
+
await createService().sendRequest(createModel('openai/o3', O_SERIES, captured), createRequest({ level: 'minimal' }));
|
|
65
|
+
expect(captured[0].reasoning).to.deep.equal({ level: 'low' });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('clamps a preference-entry level the model does not support', async () => {
|
|
69
|
+
const captured: UserRequest[] = [];
|
|
70
|
+
const service = createService([{ scope: { providerId: 'openai' }, reasoning: { level: 'minimal' } }]);
|
|
71
|
+
await service.sendRequest(createModel('openai/o3', O_SERIES, captured), createRequest());
|
|
72
|
+
expect(captured[0].reasoning).to.deep.equal({ level: 'low' });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('keeps a level the model supports', async () => {
|
|
76
|
+
const captured: UserRequest[] = [];
|
|
77
|
+
await createService().sendRequest(createModel('openai/gpt-5', GPT5, captured), createRequest({ level: 'minimal' }));
|
|
78
|
+
expect(captured[0].reasoning).to.deep.equal({ level: 'minimal' });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('leaves the level untouched for models without reasoning support', async () => {
|
|
82
|
+
const captured: UserRequest[] = [];
|
|
83
|
+
await createService().sendRequest(createModel('openai/gpt-4o', undefined, captured), createRequest({ level: 'high' }));
|
|
84
|
+
expect(captured[0].reasoning).to.deep.equal({ level: 'high' });
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -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 { AiConfigurationService, LanguageModel, LanguageModelResponse, ReasoningSettings, UserRequest } from '../common';
|
|
21
|
+
import { AiConfigurationService, LanguageModel, LanguageModelResponse, ReasoningSettings, ReasoningSupport, UserRequest } from '../common';
|
|
22
22
|
import { LanguageModelServiceImpl } from '../common/language-model-service';
|
|
23
23
|
import {
|
|
24
24
|
PREFERENCE_NAME_REQUEST_SETTINGS,
|
|
@@ -76,6 +76,14 @@ export class FrontendLanguageModelServiceImpl extends LanguageModelServiceImpl {
|
|
|
76
76
|
languageModelRequest.reasoning = { level: languageModel.reasoningSupport.defaultLevel };
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
|
+
// A persisted selection or preference entry can name a level the current model does not accept,
|
|
80
|
+
// which the provider would otherwise send as-is; clamp it to the model's declared levels.
|
|
81
|
+
if (languageModelRequest.reasoning && languageModel.reasoningSupport) {
|
|
82
|
+
const level = ReasoningSupport.clampLevel(languageModel.reasoningSupport, languageModelRequest.reasoning.level);
|
|
83
|
+
if (level !== languageModelRequest.reasoning.level) {
|
|
84
|
+
languageModelRequest.reasoning = { ...languageModelRequest.reasoning, level };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
79
87
|
|
|
80
88
|
return super.sendRequest(languageModel, languageModelRequest);
|
|
81
89
|
}
|
package/src/browser/index.ts
CHANGED
|
@@ -28,6 +28,9 @@ export * from './ai-view-contribution';
|
|
|
28
28
|
export * from './frontend-language-model-registry';
|
|
29
29
|
export * from './frontend-language-model-alias-registry';
|
|
30
30
|
export * from './hide-ai-preferences-contribution';
|
|
31
|
+
export * from './model-discovery-status-service';
|
|
32
|
+
export * from './favorite-models-service';
|
|
33
|
+
export * from './discovering-provider-contribution';
|
|
31
34
|
export * from './frontend-variable-service';
|
|
32
35
|
export * from './prompttemplate-contribution';
|
|
33
36
|
export * from './theia-variable-contribution';
|
|
@@ -0,0 +1,101 @@
|
|
|
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
|
+
import { MockLogger } from '@theia/core/lib/common/test/mock-logger';
|
|
21
|
+
|
|
22
|
+
/** The service logs a failed run, so a bare instance would have no logger to do it with. */
|
|
23
|
+
function createService(): ModelDiscoveryStatusService {
|
|
24
|
+
return Object.assign(new ModelDiscoveryStatusService(), { logger: new MockLogger() });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('ModelDiscoveryStatusService', () => {
|
|
28
|
+
|
|
29
|
+
it('registers a provider with an initial idle status', () => {
|
|
30
|
+
const service = createService();
|
|
31
|
+
service.registerProvider({ providerId: 'anthropic', label: 'Anthropic', refresh: async () => { } });
|
|
32
|
+
const status = service.getStatus('anthropic');
|
|
33
|
+
expect(status?.state).to.equal('idle');
|
|
34
|
+
expect(status?.label).to.equal('Anthropic');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('merges partial updates and fires onDidChange', () => {
|
|
38
|
+
const service = createService();
|
|
39
|
+
service.registerProvider({ providerId: 'anthropic', label: 'Anthropic', refresh: async () => { } });
|
|
40
|
+
|
|
41
|
+
const events: ModelDiscoveryStatus[] = [];
|
|
42
|
+
service.onDidChange(e => events.push(e));
|
|
43
|
+
|
|
44
|
+
service.updateStatus('anthropic', { state: 'fetching' });
|
|
45
|
+
service.updateStatus('anthropic', { state: 'ready', lastFetch: 123, fromCache: false });
|
|
46
|
+
|
|
47
|
+
const status = service.getStatus('anthropic');
|
|
48
|
+
expect(status?.state).to.equal('ready');
|
|
49
|
+
expect(status?.lastFetch).to.equal(123);
|
|
50
|
+
// label is carried over from registration.
|
|
51
|
+
expect(status?.label).to.equal('Anthropic');
|
|
52
|
+
expect(events.map(e => e.state)).to.deep.equal(['fetching', 'ready']);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('does not reset an existing status when the provider is re-registered', () => {
|
|
56
|
+
const service = createService();
|
|
57
|
+
service.registerProvider({ providerId: 'anthropic', label: 'Anthropic', refresh: async () => { } });
|
|
58
|
+
service.updateStatus('anthropic', { state: 'ready' });
|
|
59
|
+
|
|
60
|
+
service.registerProvider({ providerId: 'anthropic', label: 'Anthropic', refresh: async () => { } });
|
|
61
|
+
expect(service.getStatus('anthropic')?.state).to.equal('ready');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('reports a failure and clears what the previous state offered', () => {
|
|
65
|
+
const service = createService();
|
|
66
|
+
service.registerProvider({ providerId: 'copilot', label: 'GitHub Copilot', refresh: async () => { } });
|
|
67
|
+
service.updateStatus('copilot', {
|
|
68
|
+
state: 'no-credentials',
|
|
69
|
+
stateLabel: 'Not signed in',
|
|
70
|
+
action: { label: 'Sign in', commandId: 'copilot.signIn' }
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
service.reportError('copilot', new Error('the CLI is not talking'));
|
|
74
|
+
|
|
75
|
+
const status = service.getStatus('copilot');
|
|
76
|
+
expect(status?.state).to.equal('error');
|
|
77
|
+
expect(status?.message).to.equal('the CLI is not talking');
|
|
78
|
+
// A failure must not keep reading like the state before it.
|
|
79
|
+
expect(status?.stateLabel).to.equal(undefined);
|
|
80
|
+
expect(status?.action).to.equal(undefined);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('reports a rejection that is not an error', () => {
|
|
84
|
+
const service = createService();
|
|
85
|
+
service.registerProvider({ providerId: 'anthropic', label: 'Anthropic', refresh: async () => { } });
|
|
86
|
+
service.reportError('anthropic', 'nope');
|
|
87
|
+
expect(service.getStatus('anthropic')?.message).to.equal('nope');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('delegates refresh to the registered provider', async () => {
|
|
91
|
+
const service = createService();
|
|
92
|
+
let refreshed = false;
|
|
93
|
+
service.registerProvider({ providerId: 'anthropic', label: 'Anthropic', refresh: async () => { refreshed = true; } });
|
|
94
|
+
|
|
95
|
+
await service.refresh('anthropic');
|
|
96
|
+
expect(refreshed).to.be.true;
|
|
97
|
+
|
|
98
|
+
// Refreshing an unknown provider is a no-op and does not throw.
|
|
99
|
+
await service.refresh('unknown');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
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, ILogger } from '@theia/core';
|
|
18
|
+
import { inject, injectable, named } 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
|
+
@inject(ILogger) @named('ai-core:ModelDiscoveryStatusService')
|
|
46
|
+
protected readonly logger: ILogger;
|
|
47
|
+
|
|
48
|
+
protected readonly descriptors = new Map<string, ModelDiscoveryProviderDescriptor>();
|
|
49
|
+
protected readonly statuses = new Map<string, ModelDiscoveryStatus>();
|
|
50
|
+
|
|
51
|
+
protected readonly onDidChangeEmitter = new Emitter<ModelDiscoveryStatus>();
|
|
52
|
+
/** Fired whenever a provider's status changes; the argument is the new status. */
|
|
53
|
+
readonly onDidChange: Event<ModelDiscoveryStatus> = this.onDidChangeEmitter.event;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Register a provider. Idempotent: re-registering keeps any existing status and only
|
|
57
|
+
* updates the refresh callback and label.
|
|
58
|
+
*/
|
|
59
|
+
registerProvider(descriptor: ModelDiscoveryProviderDescriptor): void {
|
|
60
|
+
this.descriptors.set(descriptor.providerId, descriptor);
|
|
61
|
+
if (!this.statuses.has(descriptor.providerId)) {
|
|
62
|
+
this.statuses.set(descriptor.providerId, {
|
|
63
|
+
providerId: descriptor.providerId,
|
|
64
|
+
label: descriptor.label,
|
|
65
|
+
modelIdPrefix: descriptor.modelIdPrefix,
|
|
66
|
+
state: 'idle'
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
this.onDidChangeEmitter.fire(this.statuses.get(descriptor.providerId)!);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Update a provider's status. Only the provided fields are changed; the rest are carried
|
|
74
|
+
* over from the previous status. Passing `undefined` for an optional field explicitly clears it.
|
|
75
|
+
*/
|
|
76
|
+
updateStatus(providerId: string, update: Partial<Omit<ModelDiscoveryStatus, 'providerId'>>): void {
|
|
77
|
+
const previous = this.statuses.get(providerId);
|
|
78
|
+
const next: ModelDiscoveryStatus = {
|
|
79
|
+
providerId,
|
|
80
|
+
label: update.label ?? previous?.label ?? providerId,
|
|
81
|
+
modelIdPrefix: 'modelIdPrefix' in update ? update.modelIdPrefix : previous?.modelIdPrefix,
|
|
82
|
+
state: update.state ?? previous?.state ?? 'idle',
|
|
83
|
+
stateLabel: 'stateLabel' in update ? update.stateLabel : previous?.stateLabel,
|
|
84
|
+
message: 'message' in update ? update.message : previous?.message,
|
|
85
|
+
lastFetch: 'lastFetch' in update ? update.lastFetch : previous?.lastFetch,
|
|
86
|
+
fromCache: 'fromCache' in update ? update.fromCache : previous?.fromCache,
|
|
87
|
+
discovered: 'discovered' in update ? update.discovered : previous?.discovered,
|
|
88
|
+
action: 'action' in update ? update.action : previous?.action
|
|
89
|
+
};
|
|
90
|
+
this.statuses.set(providerId, next);
|
|
91
|
+
this.onDidChangeEmitter.fire(next);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Reports a failed discovery run. Any step of a run can fail, including the ones that only talk to
|
|
96
|
+
* the backend, and nothing awaits the promise a rejection would travel on: a provider's startup
|
|
97
|
+
* does not, and a refresh button cannot act on it. What the previous state offered is cleared, so
|
|
98
|
+
* a failure does not keep reading like the state before it (a badge, a sign-in button).
|
|
99
|
+
*/
|
|
100
|
+
reportError(providerId: string, error: unknown): void {
|
|
101
|
+
// A run can also fail on this side of the RPC boundary, where nothing else would record it.
|
|
102
|
+
this.logger.error(`Model discovery failed for provider '${providerId}':`, error);
|
|
103
|
+
this.updateStatus(providerId, {
|
|
104
|
+
state: 'error',
|
|
105
|
+
stateLabel: undefined,
|
|
106
|
+
message: error instanceof Error ? error.message : String(error),
|
|
107
|
+
action: undefined
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
getStatus(providerId: string): ModelDiscoveryStatus | undefined {
|
|
112
|
+
return this.statuses.get(providerId);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
getStatuses(): ModelDiscoveryStatus[] {
|
|
116
|
+
return [...this.statuses.values()];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Trigger a manual refresh for the given provider, if it is registered. */
|
|
120
|
+
async refresh(providerId: string): Promise<void> {
|
|
121
|
+
await this.descriptors.get(providerId)?.refresh();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -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;
|
package/src/common/index.ts
CHANGED
|
@@ -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
|
+
};
|
|
@@ -24,9 +24,11 @@ import {
|
|
|
24
24
|
isToolCallHtmlAppResult,
|
|
25
25
|
LanguageModel,
|
|
26
26
|
LanguageModelSelector,
|
|
27
|
+
ReasoningSupport,
|
|
27
28
|
resolveCompactionDefault,
|
|
28
29
|
resolveCompactionTokenThreshold,
|
|
29
30
|
resolveCompactionTokenThresholdDefault,
|
|
31
|
+
resolveCustomModelHeaders,
|
|
30
32
|
resolveServerSideCompaction
|
|
31
33
|
} from './language-model';
|
|
32
34
|
|
|
@@ -260,6 +262,19 @@ describe('compaction contract', () => {
|
|
|
260
262
|
expect(resolveServerSideCompaction(true, false, { enabled: true })).to.equal(true);
|
|
261
263
|
expect(resolveServerSideCompaction(true, true, { enabled: false })).to.equal(false);
|
|
262
264
|
});
|
|
265
|
+
it('resolves custom model headers, ignoring non-string and non-object values', () => {
|
|
266
|
+
expect(resolveCustomModelHeaders({ 'X-Audit-User': 'theia' })).to.deep.equal({ 'X-Audit-User': 'theia' });
|
|
267
|
+
// keys are sorted, so reordering the entries in the preference is not a change
|
|
268
|
+
expect(Object.keys(resolveCustomModelHeaders({ 'X-B': '2', 'X-A': '1' })!)).to.deep.equal(['X-A', 'X-B']);
|
|
269
|
+
// entries with a non-string value are dropped, the remaining ones are kept
|
|
270
|
+
expect(resolveCustomModelHeaders({ 'X-Audit-User': 'theia', 'X-Count': 5 })).to.deep.equal({ 'X-Audit-User': 'theia' });
|
|
271
|
+
// nothing usable -> undefined, so the default request headers stay untouched
|
|
272
|
+
expect(resolveCustomModelHeaders({ 'X-Count': 5 })).to.equal(undefined);
|
|
273
|
+
expect(resolveCustomModelHeaders({})).to.equal(undefined);
|
|
274
|
+
expect(resolveCustomModelHeaders(undefined)).to.equal(undefined);
|
|
275
|
+
expect(resolveCustomModelHeaders('X-Audit-User: theia')).to.equal(undefined);
|
|
276
|
+
expect(resolveCustomModelHeaders([['X-Audit-User', 'theia']])).to.equal(undefined);
|
|
277
|
+
});
|
|
263
278
|
});
|
|
264
279
|
|
|
265
280
|
describe('isToolCallHtmlAppResult', () => {
|
|
@@ -343,3 +358,33 @@ describe('isToolCallHtmlAppResult', () => {
|
|
|
343
358
|
expect(isToolCallHtmlAppResult({ type: 'html', html, title: 'Animated Unit Test Sandbox' })).to.be.true;
|
|
344
359
|
});
|
|
345
360
|
});
|
|
361
|
+
|
|
362
|
+
describe('ReasoningSupport.clampLevel', () => {
|
|
363
|
+
const oSeries: ReasoningSupport = { supportedLevels: ['off', 'low', 'medium', 'high', 'auto'], defaultLevel: 'auto' };
|
|
364
|
+
|
|
365
|
+
it('returns a supported level unchanged', () => {
|
|
366
|
+
expect(ReasoningSupport.clampLevel(oSeries, 'medium')).to.equal('medium');
|
|
367
|
+
expect(ReasoningSupport.clampLevel(oSeries, 'auto')).to.equal('auto');
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('raises an unsupported minimal to low rather than lowering it to off', () => {
|
|
371
|
+
expect(ReasoningSupport.clampLevel(oSeries, 'minimal')).to.equal('low');
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('lowers an unsupported level when nothing higher is supported', () => {
|
|
375
|
+
expect(ReasoningSupport.clampLevel({ supportedLevels: ['off', 'low', 'medium'] }, 'high')).to.equal('medium');
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it('raises an unsupported off to the lowest supported effort', () => {
|
|
379
|
+
expect(ReasoningSupport.clampLevel({ supportedLevels: ['low', 'high'] }, 'off')).to.equal('low');
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('resolves an unsupported auto to the default level, then to the first supported level', () => {
|
|
383
|
+
expect(ReasoningSupport.clampLevel({ supportedLevels: ['low', 'high'], defaultLevel: 'high' }, 'auto')).to.equal('high');
|
|
384
|
+
expect(ReasoningSupport.clampLevel({ supportedLevels: ['low', 'high'] }, 'auto')).to.equal('low');
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it('returns the level unchanged when the model declares no levels', () => {
|
|
388
|
+
expect(ReasoningSupport.clampLevel({ supportedLevels: [] }, 'minimal')).to.equal('minimal');
|
|
389
|
+
});
|
|
390
|
+
});
|