@theia/ai-openai 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/lib/browser/openai-frontend-application-contribution.d.ts +18 -10
- package/lib/browser/openai-frontend-application-contribution.d.ts.map +1 -1
- package/lib/browser/openai-frontend-application-contribution.js +68 -64
- package/lib/browser/openai-frontend-application-contribution.js.map +1 -1
- package/lib/common/openai-language-models-manager.d.ts +20 -1
- package/lib/common/openai-language-models-manager.d.ts.map +1 -1
- package/lib/common/openai-preferences.d.ts +2 -1
- package/lib/common/openai-preferences.d.ts.map +1 -1
- package/lib/common/openai-preferences.js +23 -13
- package/lib/common/openai-preferences.js.map +1 -1
- package/lib/node/openai-language-model.d.ts +26 -2
- package/lib/node/openai-language-model.d.ts.map +1 -1
- package/lib/node/openai-language-model.js +44 -18
- package/lib/node/openai-language-model.js.map +1 -1
- package/lib/node/openai-language-model.spec.js +55 -8
- package/lib/node/openai-language-model.spec.js.map +1 -1
- package/lib/node/openai-language-models-manager-impl.d.ts +38 -1
- package/lib/node/openai-language-models-manager-impl.d.ts.map +1 -1
- package/lib/node/openai-language-models-manager-impl.js +88 -3
- package/lib/node/openai-language-models-manager-impl.js.map +1 -1
- package/lib/node/openai-language-models-manager-impl.spec.js +171 -0
- package/lib/node/openai-language-models-manager-impl.spec.js.map +1 -1
- package/lib/node/openai-model-defaults.d.ts.map +1 -1
- package/lib/node/openai-model-defaults.js +7 -0
- package/lib/node/openai-model-defaults.js.map +1 -1
- package/lib/node/openai-model-defaults.spec.js +9 -0
- package/lib/node/openai-model-defaults.spec.js.map +1 -1
- package/lib/node/openai-reasoning.d.ts +1 -2
- package/lib/node/openai-reasoning.d.ts.map +1 -1
- package/lib/node/openai-reasoning.js +6 -8
- package/lib/node/openai-reasoning.js.map +1 -1
- package/lib/node/openai-response-api-utils.d.ts +25 -1
- package/lib/node/openai-response-api-utils.d.ts.map +1 -1
- package/lib/node/openai-response-api-utils.js +104 -12
- package/lib/node/openai-response-api-utils.js.map +1 -1
- package/lib/node/openai-response-api-utils.spec.js +146 -0
- package/lib/node/openai-response-api-utils.spec.js.map +1 -1
- package/package.json +7 -7
- package/src/browser/openai-frontend-application-contribution.ts +82 -69
- package/src/common/openai-language-models-manager.ts +21 -2
- package/src/common/openai-preferences.ts +25 -12
- package/src/node/openai-language-model.spec.ts +63 -9
- package/src/node/openai-language-model.ts +58 -20
- package/src/node/openai-language-models-manager-impl.spec.ts +178 -0
- package/src/node/openai-language-models-manager-impl.ts +106 -6
- package/src/node/openai-model-defaults.spec.ts +10 -0
- package/src/node/openai-model-defaults.ts +8 -0
- package/src/node/openai-reasoning.ts +6 -9
- package/src/node/openai-response-api-utils.spec.ts +184 -1
- package/src/node/openai-response-api-utils.ts +115 -13
|
@@ -14,89 +14,95 @@
|
|
|
14
14
|
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
|
15
15
|
// *****************************************************************************
|
|
16
16
|
|
|
17
|
-
import { FrontendApplicationContribution } from '@theia/core/lib/browser';
|
|
18
17
|
import { inject, injectable } from '@theia/core/shared/inversify';
|
|
19
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
ReasoningSupport, resolveCompactionDefault, resolveCompactionTokenThresholdDefault, resolveCustomModelHeaders, ServerSideCompactionSetting
|
|
20
|
+
} from '@theia/ai-core';
|
|
21
|
+
import { DiscoveringProviderContribution, ModelDiscoveryMessages } from '@theia/ai-core/lib/browser';
|
|
22
|
+
import { DiscoveredModel } from '@theia/ai-core/lib/common';
|
|
20
23
|
import { OpenAiLanguageModelsManager, OpenAiModelDescription, OPENAI_PROVIDER_ID } from '../common';
|
|
21
24
|
import {
|
|
22
|
-
API_KEY_PREF, CUSTOM_ENDPOINTS_PREF,
|
|
25
|
+
ALLOW_ENV_API_KEY_PREF, API_KEY_PREF, CUSTOM_ENDPOINTS_PREF, MODEL_OVERRIDES_PREF, SERVER_SIDE_COMPACTION_PREF, SERVER_SIDE_COMPACTION_TOKEN_THRESHOLD_PREF,
|
|
26
|
+
USE_RESPONSE_API_PREF
|
|
23
27
|
} from '../common/openai-preferences';
|
|
24
28
|
import {
|
|
25
|
-
AICorePreferences, PREFERENCE_NAME_MAX_RETRIES, PREFERENCE_NAME_SERVER_SIDE_COMPACTION,
|
|
29
|
+
AICorePreferences, PREFERENCE_NAME_MAX_RETRIES, PREFERENCE_NAME_SERVER_SIDE_COMPACTION,
|
|
30
|
+
PREFERENCE_NAME_SERVER_SIDE_COMPACTION_TOKEN_THRESHOLD
|
|
26
31
|
} from '@theia/ai-core/lib/common/ai-core-preferences';
|
|
27
|
-
import {
|
|
32
|
+
import { nls, PreferenceChange } from '@theia/core';
|
|
28
33
|
|
|
29
|
-
|
|
30
|
-
|
|
34
|
+
/** Provider node id (matches the `ai-features.openAiOfficial.*` configuration segment). */
|
|
35
|
+
const OPENAI_OFFICIAL_PROVIDER_ID = 'openAiOfficial';
|
|
31
36
|
|
|
32
|
-
|
|
33
|
-
|
|
37
|
+
@injectable()
|
|
38
|
+
export class OpenAiFrontendApplicationContribution extends DiscoveringProviderContribution<OpenAiModelDescription> {
|
|
34
39
|
|
|
35
40
|
@inject(OpenAiLanguageModelsManager)
|
|
36
|
-
protected manager: OpenAiLanguageModelsManager;
|
|
41
|
+
protected readonly manager: OpenAiLanguageModelsManager;
|
|
37
42
|
|
|
38
43
|
@inject(AICorePreferences)
|
|
39
44
|
protected aiCorePreferences: AICorePreferences;
|
|
40
45
|
|
|
41
|
-
protected
|
|
42
|
-
protected
|
|
46
|
+
protected readonly providerId = OPENAI_OFFICIAL_PROVIDER_ID;
|
|
47
|
+
protected readonly providerLabel = 'OpenAI';
|
|
48
|
+
protected readonly modelOverridesPreference = MODEL_OVERRIDES_PREF;
|
|
49
|
+
protected readonly allowEnvironmentApiKeyPreference = ALLOW_ENV_API_KEY_PREF;
|
|
43
50
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const proxyUri = this.preferenceService.get<string>('http.proxy', undefined);
|
|
50
|
-
this.manager.setProxyUrl(proxyUri);
|
|
51
|
-
|
|
52
|
-
const models = this.preferenceService.get<string[]>(MODELS_PREF, []);
|
|
53
|
-
this.manager.createOrUpdateLanguageModels(...models.map(modelId => this.createOpenAIModelDescription(modelId)));
|
|
54
|
-
this.prevModels = [...models];
|
|
55
|
-
|
|
56
|
-
const customModels = this.preferenceService.get<Partial<OpenAiModelDescription>[]>(CUSTOM_ENDPOINTS_PREF, []);
|
|
57
|
-
this.manager.createOrUpdateLanguageModels(...this.createCustomModelDescriptionsFromPreferences(customModels));
|
|
58
|
-
this.prevCustomModels = [...customModels];
|
|
59
|
-
|
|
60
|
-
this.preferenceService.onPreferenceChanged(event => {
|
|
61
|
-
if (event.preferenceName === API_KEY_PREF) {
|
|
62
|
-
this.manager.setApiKey(this.preferenceService.get<string>(API_KEY_PREF, undefined));
|
|
63
|
-
this.updateAllModels();
|
|
64
|
-
} else if (event.preferenceName === MODELS_PREF) {
|
|
65
|
-
this.handleModelChanges(this.preferenceService.get<string[]>(MODELS_PREF, []));
|
|
66
|
-
} else if (event.preferenceName === CUSTOM_ENDPOINTS_PREF) {
|
|
67
|
-
this.handleCustomModelChanges(this.preferenceService.get<Partial<OpenAiModelDescription>[]>(CUSTOM_ENDPOINTS_PREF, []));
|
|
68
|
-
} else if (event.preferenceName === USE_RESPONSE_API_PREF) {
|
|
69
|
-
this.updateAllModels();
|
|
70
|
-
} else if (event.preferenceName === SERVER_SIDE_COMPACTION_PREF) {
|
|
71
|
-
this.updateAllModels();
|
|
72
|
-
} else if (event.preferenceName === PREFERENCE_NAME_SERVER_SIDE_COMPACTION ||
|
|
73
|
-
event.preferenceName === SERVER_SIDE_COMPACTION_TOKEN_THRESHOLD_PREF ||
|
|
74
|
-
event.preferenceName === PREFERENCE_NAME_SERVER_SIDE_COMPACTION_TOKEN_THRESHOLD) {
|
|
75
|
-
this.updateAllModels();
|
|
76
|
-
} else if (event.preferenceName === 'http.proxy') {
|
|
77
|
-
this.manager.setProxyUrl(this.preferenceService.get<string>('http.proxy', undefined));
|
|
78
|
-
this.updateAllModels();
|
|
79
|
-
}
|
|
80
|
-
});
|
|
51
|
+
/** The registered ids carry the `openai/` prefix, while the provider node is `openAiOfficial`. */
|
|
52
|
+
protected override get modelIdPrefix(): string {
|
|
53
|
+
return OPENAI_PROVIDER_ID;
|
|
54
|
+
}
|
|
81
55
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
56
|
+
protected prevCustomModels: Partial<OpenAiModelDescription>[] = [];
|
|
57
|
+
|
|
58
|
+
protected get discoveryMessages(): ModelDiscoveryMessages {
|
|
59
|
+
return {
|
|
60
|
+
noCredentials: nls.localize('theia/ai/openai/discovery/noKey', 'No OpenAI API key set. Add a key to discover models.'),
|
|
61
|
+
consentRequired: nls.localize('theia/ai/openai/discovery/consentRequired',
|
|
62
|
+
'An OpenAI API key was found in the environment. Confirm its use to discover models.'),
|
|
63
|
+
consentPrompt: nls.localize('theia/ai/openai/discovery/consentPrompt',
|
|
64
|
+
'An OpenAI API key was found in the environment (OPENAI_API_KEY). Allow Theia to use it to discover and call OpenAI models?'),
|
|
65
|
+
useEnvironmentKey: nls.localize('theia/ai/openai/discovery/useEnvKey', 'Use key'),
|
|
66
|
+
overridden: nls.localize('theia/ai/openai/discovery/overridden',
|
|
67
|
+
'The model list is configured manually. Clear the model overrides to discover the models from the provider again.'),
|
|
68
|
+
cached: error => nls.localize('theia/ai/openai/discovery/cached', 'Showing cached models; last refresh failed: {0}', error)
|
|
69
|
+
};
|
|
88
70
|
}
|
|
89
71
|
|
|
90
|
-
protected
|
|
91
|
-
|
|
92
|
-
|
|
72
|
+
protected override initializeProvider(): void {
|
|
73
|
+
this.manager.setApiKey(this.preferenceService.get<string>(API_KEY_PREF, undefined));
|
|
74
|
+
this.manager.setProxyUrl(this.preferenceService.get<string>('http.proxy', undefined));
|
|
93
75
|
|
|
94
|
-
const
|
|
95
|
-
|
|
76
|
+
const customModels = this.preferenceService.get<Partial<OpenAiModelDescription>[]>(CUSTOM_ENDPOINTS_PREF, []);
|
|
77
|
+
this.manager.createOrUpdateLanguageModels(...this.createCustomModelDescriptionsFromPreferences(customModels));
|
|
78
|
+
this.prevCustomModels = [...customModels];
|
|
96
79
|
|
|
97
|
-
this.
|
|
98
|
-
|
|
99
|
-
|
|
80
|
+
this.aiCorePreferences.onPreferenceChanged(event => {
|
|
81
|
+
if (event.preferenceName === PREFERENCE_NAME_MAX_RETRIES) {
|
|
82
|
+
this.updateDiscoveredModels();
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
protected override handlePreferenceChange(event: PreferenceChange): void {
|
|
88
|
+
if (event.preferenceName === API_KEY_PREF) {
|
|
89
|
+
this.manager.setApiKey(this.preferenceService.get<string>(API_KEY_PREF, undefined));
|
|
90
|
+
this.discoverAndRegisterModels();
|
|
91
|
+
} else if (event.preferenceName === CUSTOM_ENDPOINTS_PREF) {
|
|
92
|
+
this.handleCustomModelChanges(this.preferenceService.get<Partial<OpenAiModelDescription>[]>(CUSTOM_ENDPOINTS_PREF, []));
|
|
93
|
+
} else if (event.preferenceName === USE_RESPONSE_API_PREF ||
|
|
94
|
+
event.preferenceName === SERVER_SIDE_COMPACTION_PREF ||
|
|
95
|
+
event.preferenceName === PREFERENCE_NAME_SERVER_SIDE_COMPACTION ||
|
|
96
|
+
event.preferenceName === SERVER_SIDE_COMPACTION_TOKEN_THRESHOLD_PREF ||
|
|
97
|
+
event.preferenceName === PREFERENCE_NAME_SERVER_SIDE_COMPACTION_TOKEN_THRESHOLD) {
|
|
98
|
+
this.updateDiscoveredModels();
|
|
99
|
+
} else if (event.preferenceName === 'http.proxy') {
|
|
100
|
+
this.manager.setProxyUrl(this.preferenceService.get<string>('http.proxy', undefined));
|
|
101
|
+
// A model keeps the proxy it was registered with, so the custom endpoints have to be
|
|
102
|
+
// re-registered as well: discovery only covers the models it registers itself.
|
|
103
|
+
this.updateCustomModels();
|
|
104
|
+
this.discoverAndRegisterModels();
|
|
105
|
+
}
|
|
100
106
|
}
|
|
101
107
|
|
|
102
108
|
protected handleCustomModelChanges(newCustomModels: Partial<OpenAiModelDescription>[]): void {
|
|
@@ -118,23 +124,28 @@ export class OpenAiFrontendApplicationContribution implements FrontendApplicatio
|
|
|
118
124
|
model.useResponseApi === newModel.useResponseApi &&
|
|
119
125
|
model.serverSideCompactionEnabledByDefault === newModel.serverSideCompactionEnabledByDefault &&
|
|
120
126
|
model.serverSideCompactionTokenThresholdByDefault === newModel.serverSideCompactionTokenThresholdByDefault &&
|
|
121
|
-
reasoningSupportEquals(model.reasoningSupport, newModel.reasoningSupport)
|
|
127
|
+
reasoningSupportEquals(model.reasoningSupport, newModel.reasoningSupport) &&
|
|
128
|
+
JSON.stringify(model.headers) === JSON.stringify(newModel.headers)));
|
|
122
129
|
|
|
123
130
|
this.manager.removeLanguageModels(...modelsToRemove.map(model => model.id));
|
|
124
131
|
this.manager.createOrUpdateLanguageModels(...modelsToAddOrUpdate);
|
|
125
132
|
this.prevCustomModels = [...newCustomModels];
|
|
126
133
|
}
|
|
127
134
|
|
|
128
|
-
protected
|
|
129
|
-
|
|
130
|
-
this.
|
|
135
|
+
protected override updateDiscoveredModels(): void {
|
|
136
|
+
super.updateDiscoveredModels();
|
|
137
|
+
this.updateCustomModels();
|
|
138
|
+
}
|
|
131
139
|
|
|
140
|
+
/** Re-applies the model descriptions of the configured custom endpoints. */
|
|
141
|
+
protected updateCustomModels(): void {
|
|
132
142
|
const customModels = this.preferenceService.get<Partial<OpenAiModelDescription>[]>(CUSTOM_ENDPOINTS_PREF, []);
|
|
133
143
|
this.manager.createOrUpdateLanguageModels(...this.createCustomModelDescriptionsFromPreferences(customModels));
|
|
134
144
|
}
|
|
135
145
|
|
|
136
146
|
/** Per-model capabilities are resolved by the backend from the model id; see `openai-model-defaults.ts`. */
|
|
137
|
-
protected
|
|
147
|
+
protected createModelDescription(model: DiscoveredModel): OpenAiModelDescription {
|
|
148
|
+
const modelId = model.id;
|
|
138
149
|
const id = `${OPENAI_PROVIDER_ID}/${modelId}`;
|
|
139
150
|
const maxRetries = this.aiCorePreferences.get(PREFERENCE_NAME_MAX_RETRIES) ?? 3;
|
|
140
151
|
const useResponseApi = this.preferenceService.get<boolean>(USE_RESPONSE_API_PREF, false);
|
|
@@ -152,7 +163,8 @@ export class OpenAiFrontendApplicationContribution implements FrontendApplicatio
|
|
|
152
163
|
maxRetries: maxRetries,
|
|
153
164
|
useResponseApi: useResponseApi,
|
|
154
165
|
serverSideCompactionEnabledByDefault,
|
|
155
|
-
serverSideCompactionTokenThresholdByDefault
|
|
166
|
+
serverSideCompactionTokenThresholdByDefault,
|
|
167
|
+
released: model.released
|
|
156
168
|
};
|
|
157
169
|
}
|
|
158
170
|
|
|
@@ -184,6 +196,7 @@ export class OpenAiFrontendApplicationContribution implements FrontendApplicatio
|
|
|
184
196
|
enableStreaming: pref.enableStreaming,
|
|
185
197
|
maxRetries: pref.maxRetries ?? maxRetries,
|
|
186
198
|
useResponseApi: pref.useResponseApi ?? false,
|
|
199
|
+
headers: resolveCustomModelHeaders(pref.headers),
|
|
187
200
|
reasoningSupport: isReasoningSupport(pref.reasoningSupport) ? pref.reasoningSupport : undefined,
|
|
188
201
|
serverSideCompactionEnabledByDefault,
|
|
189
202
|
serverSideCompactionTokenThresholdByDefault
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
//
|
|
14
14
|
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
|
15
15
|
// *****************************************************************************
|
|
16
|
-
import { ReasoningSupport } from '@theia/ai-core';
|
|
16
|
+
import { ApiKeySource, ModelDiscoveryResult, ReasoningSupport } from '@theia/ai-core';
|
|
17
17
|
|
|
18
18
|
export const OPENAI_LANGUAGE_MODELS_MANAGER_PATH = '/services/open-ai/language-model-manager';
|
|
19
19
|
export const OpenAiLanguageModelsManager = Symbol('OpenAiLanguageModelsManager');
|
|
@@ -35,6 +35,8 @@ export interface OpenAiModelDescription {
|
|
|
35
35
|
deployment?: string;
|
|
36
36
|
/** Maximum number of retry attempts when a request fails. Default is 3. */
|
|
37
37
|
maxRetries: number;
|
|
38
|
+
/** Additional HTTP headers sent with every request to the endpoint, e.g. headers required by a gateway in front of the API. */
|
|
39
|
+
headers?: Record<string, string>;
|
|
38
40
|
/** Use the newer OpenAI Response API instead of the Chat Completion API. Default is `false`. */
|
|
39
41
|
useResponseApi?: boolean;
|
|
40
42
|
/** Indicate whether the streaming API shall be used. Defaults from the model id when unset. */
|
|
@@ -54,12 +56,29 @@ export interface OpenAiModelDescription {
|
|
|
54
56
|
serverSideCompactionEnabledByDefault?: boolean;
|
|
55
57
|
/** Resolved default input-token threshold for server-side compaction. `undefined` preserves the provider default. */
|
|
56
58
|
serverSideCompactionTokenThresholdByDefault?: number;
|
|
59
|
+
/** Release date (ms since epoch) reported by discovery, surfaced as {@link LanguageModelMetaData.released}. */
|
|
60
|
+
released?: number;
|
|
57
61
|
}
|
|
58
62
|
export interface OpenAiLanguageModelsManager {
|
|
59
63
|
apiKey: string | undefined;
|
|
60
64
|
setApiKey(key: string | undefined): void;
|
|
65
|
+
/**
|
|
66
|
+
* Allows or refuses the use of an API key found in the environment. Refused by default: an
|
|
67
|
+
* environment key is only used once the user has confirmed it, and setting this back to `false`
|
|
68
|
+
* stops it being used immediately, wherever it would have been used.
|
|
69
|
+
*/
|
|
70
|
+
setAllowEnvironmentApiKey(allowed: boolean): void;
|
|
61
71
|
setApiVersion(version: string | undefined): void;
|
|
62
72
|
setProxyUrl(proxyUrl: string | undefined): void;
|
|
63
73
|
createOrUpdateLanguageModels(...models: OpenAiModelDescription[]): Promise<void>;
|
|
64
|
-
removeLanguageModels(...modelIds: string[]): void
|
|
74
|
+
removeLanguageModels(...modelIds: string[]): void;
|
|
75
|
+
/**
|
|
76
|
+
* Fetches the ids of the text-chat models OpenAI currently offers from its `/v1/models` endpoint,
|
|
77
|
+
* retrying transient connection failures and caching a snapshot for offline use. The endpoint
|
|
78
|
+
* exposes no capability metadata, so non-chat models are filtered out heuristically. Returns an
|
|
79
|
+
* empty result when no key is configured; falls back to the cached snapshot on failure.
|
|
80
|
+
*/
|
|
81
|
+
fetchAvailableModels(): Promise<ModelDiscoveryResult>;
|
|
82
|
+
/** Reports where the effective API key comes from (preference, environment, or none). */
|
|
83
|
+
getApiKeySource(): Promise<ApiKeySource>;
|
|
65
84
|
}
|
|
@@ -21,7 +21,8 @@ import { SERVER_SIDE_COMPACTION_TOKEN_THRESHOLD_MINIMUM } from '@theia/ai-core/l
|
|
|
21
21
|
import { LINUX_ENV_HINT, nls, PreferenceSchema } from '@theia/core';
|
|
22
22
|
|
|
23
23
|
export const API_KEY_PREF = 'ai-features.openAiOfficial.openAiApiKey';
|
|
24
|
-
export const
|
|
24
|
+
export const ALLOW_ENV_API_KEY_PREF = 'ai-features.openAiOfficial.allowEnvironmentApiKey';
|
|
25
|
+
export const MODEL_OVERRIDES_PREF = 'ai-features.openAiOfficial.modelOverrides';
|
|
25
26
|
export const USE_RESPONSE_API_PREF = 'ai-features.openAiOfficial.useResponseApi';
|
|
26
27
|
export const SERVER_SIDE_COMPACTION_PREF = 'ai-features.openAiOfficial.serverSideCompaction';
|
|
27
28
|
export const SERVER_SIDE_COMPACTION_TOKEN_THRESHOLD_PREF = 'ai-features.openAiOfficial.serverSideCompactionTokenThreshold';
|
|
@@ -37,20 +38,24 @@ export const OpenAiPreferencesSchema: PreferenceSchema = {
|
|
|
37
38
|
on the machine running Theia. Use the environment variable `OPENAI_API_KEY` to set the key securely.') + LINUX_ENV_HINT,
|
|
38
39
|
title: AI_CORE_PREFERENCES_TITLE,
|
|
39
40
|
},
|
|
40
|
-
[
|
|
41
|
-
type: '
|
|
42
|
-
|
|
41
|
+
[ALLOW_ENV_API_KEY_PREF]: {
|
|
42
|
+
type: 'boolean',
|
|
43
|
+
default: false,
|
|
43
44
|
title: AI_CORE_PREFERENCES_TITLE,
|
|
44
|
-
|
|
45
|
-
'
|
|
46
|
-
'
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
],
|
|
45
|
+
markdownDescription: nls.localize('theia/ai/openai/allowEnvApiKey/description',
|
|
46
|
+
'Allow Theia to use an OpenAI API key found in the environment (`OPENAI_API_KEY`). '
|
|
47
|
+
+ 'You are asked to confirm this once before the key is used; set it back to `false` to revoke consent.'),
|
|
48
|
+
},
|
|
49
|
+
[MODEL_OVERRIDES_PREF]: {
|
|
50
|
+
type: 'array',
|
|
51
|
+
default: [],
|
|
51
52
|
items: {
|
|
52
53
|
type: 'string'
|
|
53
|
-
}
|
|
54
|
+
},
|
|
55
|
+
title: AI_CORE_PREFERENCES_TITLE,
|
|
56
|
+
markdownDescription: nls.localize('theia/ai/openai/modelOverrides/description',
|
|
57
|
+
'Override the models discovered from OpenAI. When empty (default), the available models are discovered from the provider. '
|
|
58
|
+
+ 'Set explicit model ids to use exactly those instead; discovery is then not used at all.')
|
|
54
59
|
},
|
|
55
60
|
[USE_RESPONSE_API_PREF]: {
|
|
56
61
|
type: 'boolean',
|
|
@@ -117,6 +122,8 @@ on the machine running Theia. Use the environment variable `OPENAI_API_KEY` to s
|
|
|
117
122
|
- specify `reasoningSupport` to opt in to the chat reasoning selector. Provide an object with\
|
|
118
123
|
`supportedLevels` (e.g. `["off", "low", "medium", "high", "auto"]`) and an optional `defaultLevel`.\
|
|
119
124
|
\n\
|
|
125
|
+
- specify `headers` to send additional HTTP headers with every request to the endpoint, e.g. headers required by a gateway in front of the API.\
|
|
126
|
+
\n\
|
|
120
127
|
Refer to [our documentation](https://theia-ide.org/docs/user_ai/#openai-compatible-models-eg-via-vllm) for more information.'),
|
|
121
128
|
default: [],
|
|
122
129
|
items: {
|
|
@@ -191,6 +198,12 @@ on the machine running Theia. Use the environment variable `OPENAI_API_KEY` to s
|
|
|
191
198
|
enum: ['off', 'minimal', 'low', 'medium', 'high', 'auto']
|
|
192
199
|
}
|
|
193
200
|
}
|
|
201
|
+
},
|
|
202
|
+
headers: {
|
|
203
|
+
type: 'object',
|
|
204
|
+
additionalProperties: { type: 'string' },
|
|
205
|
+
title: nls.localize('theia/ai/openai/customEndpoints/headers/title',
|
|
206
|
+
'Additional HTTP headers sent with every request to the endpoint'),
|
|
194
207
|
}
|
|
195
208
|
}
|
|
196
209
|
}
|
|
@@ -17,9 +17,10 @@
|
|
|
17
17
|
import { expect } from 'chai';
|
|
18
18
|
import { LanguageModelMessage, LanguageModelRequest, LanguageModelResponse, ReasoningSupport, UserRequest } from '@theia/ai-core';
|
|
19
19
|
import { OpenAI } from 'openai';
|
|
20
|
-
import { OpenAiModel, OpenAiModelUtils } from './openai-language-model';
|
|
20
|
+
import { MistralFixedOpenAI, OpenAiModel, OpenAiModelUtils } from './openai-language-model';
|
|
21
21
|
import { OpenAiResponseApiUtils } from './openai-response-api-utils';
|
|
22
22
|
import { OPENAI_WEB_SEARCH } from './openai-server-tools';
|
|
23
|
+
import type { FinalRequestOptions } from 'openai/internal/request-options';
|
|
23
24
|
|
|
24
25
|
const GPT5_REASONING_SUPPORT: ReasoningSupport = {
|
|
25
26
|
supportedLevels: ['off', 'minimal', 'low', 'medium', 'high', 'auto'],
|
|
@@ -80,35 +81,47 @@ describe('OpenAiModel reasoning translation', () => {
|
|
|
80
81
|
it('maps level=minimal to reasoning.effort=minimal', () => {
|
|
81
82
|
const model = createModel('gpt-5', GPT5_REASONING_SUPPORT);
|
|
82
83
|
const result = model.callGetSettings({ messages: [], reasoning: { level: 'minimal' } }, true);
|
|
83
|
-
expect(result.reasoning).to.deep.equal({ effort: 'minimal' });
|
|
84
|
+
expect(result.reasoning).to.deep.equal({ effort: 'minimal', summary: 'auto' });
|
|
84
85
|
});
|
|
85
86
|
it('maps level=high to reasoning.effort=high', () => {
|
|
86
87
|
const model = createModel('gpt-5', GPT5_REASONING_SUPPORT);
|
|
87
88
|
const result = model.callGetSettings({ messages: [], reasoning: { level: 'high' } }, true);
|
|
88
|
-
expect(result.reasoning).to.deep.equal({ effort: 'high' });
|
|
89
|
+
expect(result.reasoning).to.deep.equal({ effort: 'high', summary: 'auto' });
|
|
89
90
|
});
|
|
90
91
|
it('omits reasoning entirely when level=off', () => {
|
|
91
92
|
const model = createModel('gpt-5', GPT5_REASONING_SUPPORT);
|
|
92
93
|
const result = model.callGetSettings({ messages: [], reasoning: { level: 'off' } }, true);
|
|
93
94
|
expect(result.reasoning).to.equal(undefined);
|
|
94
95
|
});
|
|
95
|
-
it('
|
|
96
|
+
it('requests reasoning summaries for level=auto without constraining effort', () => {
|
|
96
97
|
const model = createModel('gpt-5', GPT5_REASONING_SUPPORT);
|
|
97
98
|
const result = model.callGetSettings({ messages: [], reasoning: { level: 'auto' } }, true);
|
|
98
|
-
expect(result.reasoning).to.equal(
|
|
99
|
+
expect(result.reasoning).to.deep.equal({ summary: 'auto' });
|
|
100
|
+
});
|
|
101
|
+
it('keeps user-configured reasoning fields but lets the selected level decide effort', () => {
|
|
102
|
+
const model = createModel('gpt-5', GPT5_REASONING_SUPPORT);
|
|
103
|
+
const result = model.callGetSettings({
|
|
104
|
+
messages: [], reasoning: { level: 'high' }, settings: { reasoning: { effort: 'low', summary: 'concise' } }
|
|
105
|
+
}, true);
|
|
106
|
+
expect(result.reasoning).to.deep.equal({ effort: 'high', summary: 'concise' });
|
|
107
|
+
});
|
|
108
|
+
it('keeps a user-configured reasoning.summary for level=auto', () => {
|
|
109
|
+
const model = createModel('gpt-5', GPT5_REASONING_SUPPORT);
|
|
110
|
+
const result = model.callGetSettings({ messages: [], reasoning: { level: 'auto' }, settings: { reasoning: { summary: 'detailed' } } }, true);
|
|
111
|
+
expect(result.reasoning).to.deep.equal({ summary: 'detailed' });
|
|
99
112
|
});
|
|
100
113
|
});
|
|
101
114
|
|
|
102
|
-
describe('Chat Completions API
|
|
115
|
+
describe('Chat Completions API', () => {
|
|
103
116
|
it('maps level=medium to reasoning_effort=medium', () => {
|
|
104
117
|
const model = createModel('o3-mini', O_SERIES_REASONING_SUPPORT);
|
|
105
118
|
const result = model.callGetSettings({ messages: [], reasoning: { level: 'medium' } }, false);
|
|
106
119
|
expect(result.reasoning_effort).to.equal('medium');
|
|
107
120
|
});
|
|
108
|
-
it('
|
|
109
|
-
const model = createModel('
|
|
121
|
+
it('passes minimal through (GPT-5 accepts it; models that do not exclude it from their supportedLevels)', () => {
|
|
122
|
+
const model = createModel('gpt-5', GPT5_REASONING_SUPPORT);
|
|
110
123
|
const result = model.callGetSettings({ messages: [], reasoning: { level: 'minimal' } }, false);
|
|
111
|
-
expect(result.reasoning_effort).to.equal('
|
|
124
|
+
expect(result.reasoning_effort).to.equal('minimal');
|
|
112
125
|
});
|
|
113
126
|
it('omits reasoning_effort for level=off', () => {
|
|
114
127
|
const model = createModel('o3-mini', O_SERIES_REASONING_SUPPORT);
|
|
@@ -179,6 +192,47 @@ describe('OpenAiModel Response API fallback', () => {
|
|
|
179
192
|
});
|
|
180
193
|
});
|
|
181
194
|
|
|
195
|
+
class TestableMistralFixedOpenAI extends MistralFixedOpenAI {
|
|
196
|
+
callPrepareOptions(options: FinalRequestOptions): Promise<void> {
|
|
197
|
+
return this.prepareOptions(options);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
describe('MistralFixedOpenAI request preparation', () => {
|
|
202
|
+
|
|
203
|
+
const client = new TestableMistralFixedOpenAI({ apiKey: 'test-key' });
|
|
204
|
+
|
|
205
|
+
it('leaves a request without a body alone', async () => {
|
|
206
|
+
// `GET /models`, which model discovery issues, carries no body at all.
|
|
207
|
+
const options = { method: 'get', path: '/models' } as FinalRequestOptions;
|
|
208
|
+
await client.callPrepareOptions(options);
|
|
209
|
+
expect(options.body).to.equal(undefined);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('leaves a body without messages alone', async () => {
|
|
213
|
+
const options = { method: 'post', path: '/embeddings', body: { input: 'hello' } } as FinalRequestOptions;
|
|
214
|
+
await client.callPrepareOptions(options);
|
|
215
|
+
expect(options.body).to.deep.equal({ input: 'hello' });
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('replaces the null refusal of an assistant tool call with undefined', async () => {
|
|
219
|
+
const options = {
|
|
220
|
+
method: 'post',
|
|
221
|
+
path: '/chat/completions',
|
|
222
|
+
body: {
|
|
223
|
+
messages: [
|
|
224
|
+
// eslint-disable-next-line no-null/no-null
|
|
225
|
+
{ role: 'assistant', tool_calls: [{ id: 't1' }], refusal: null, parsed: null }
|
|
226
|
+
]
|
|
227
|
+
}
|
|
228
|
+
} as FinalRequestOptions;
|
|
229
|
+
await client.callPrepareOptions(options);
|
|
230
|
+
const message = (options.body as { messages: Array<Record<string, unknown>> }).messages[0];
|
|
231
|
+
expect(message.refusal).to.equal(undefined);
|
|
232
|
+
expect(message.parsed).to.equal(undefined);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
182
236
|
describe('OpenAiModelUtils Chat Completions processMessages', () => {
|
|
183
237
|
it('drops a CompactionMessage without throwing when useResponseApi is off', () => {
|
|
184
238
|
const utils = new OpenAiModelUtils();
|
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
ServerToolDescriptor,
|
|
31
31
|
formatToolCallContentForModel
|
|
32
32
|
} from '@theia/ai-core';
|
|
33
|
-
import { CancellationToken } from '@theia/core';
|
|
33
|
+
import { CancellationToken, isObject } from '@theia/core';
|
|
34
34
|
import { injectable } from '@theia/core/shared/inversify';
|
|
35
35
|
import { OpenAI, AzureOpenAI } from 'openai';
|
|
36
36
|
import { ChatCompletionStream } from 'openai/lib/ChatCompletionStream';
|
|
@@ -46,9 +46,11 @@ import { createProxyFetch } from '@theia/ai-core/lib/node';
|
|
|
46
46
|
|
|
47
47
|
export class MistralFixedOpenAI extends OpenAI {
|
|
48
48
|
protected override async prepareOptions(options: FinalRequestOptions): Promise<void> {
|
|
49
|
-
|
|
49
|
+
// Every request the client issues passes through here, including the body-less `GET /models`
|
|
50
|
+
// that model discovery runs, so the body cannot be assumed to exist, let alone to carry messages.
|
|
51
|
+
const messages = (options.body as { messages?: Array<ChatCompletionMessageParam> } | undefined)?.messages;
|
|
50
52
|
if (Array.isArray(messages)) {
|
|
51
|
-
|
|
53
|
+
messages.forEach(m => {
|
|
52
54
|
if (m.role === 'assistant' && m.tool_calls) {
|
|
53
55
|
// Mistral OpenAI Endpoint expects refusal to be undefined and not null for optional properties
|
|
54
56
|
// eslint-disable-next-line no-null/no-null
|
|
@@ -71,6 +73,36 @@ export const OpenAiModelIdentifier = Symbol('OpenAiModelIdentifier');
|
|
|
71
73
|
|
|
72
74
|
export type DeveloperMessageSettings = 'user' | 'system' | 'developer' | 'mergeWithFollowingUserMessage' | 'skip';
|
|
73
75
|
|
|
76
|
+
/** Options for {@link createOpenAiClient}. */
|
|
77
|
+
export interface OpenAiClientOptions {
|
|
78
|
+
/** The key to authenticate with. A custom endpoint may need none. */
|
|
79
|
+
readonly apiKey: string | undefined;
|
|
80
|
+
/** Base URL of a custom endpoint; the SDK's own default is used without one. */
|
|
81
|
+
readonly baseURL?: string;
|
|
82
|
+
/** Azure API version. Its presence is what selects the Azure client over the plain one. */
|
|
83
|
+
readonly apiVersion?: string;
|
|
84
|
+
readonly deployment?: string;
|
|
85
|
+
readonly proxyUrl?: string;
|
|
86
|
+
/** Additional HTTP headers sent with every request, e.g. headers required by a gateway in front of the API. */
|
|
87
|
+
readonly headers?: Record<string, string>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The single place an OpenAI SDK client is built, so that a chat request and the model discovery
|
|
92
|
+
* reach the provider the same way: through the configured proxy, with a key the SDK accepts, and
|
|
93
|
+
* with the Azure client where an API version says so.
|
|
94
|
+
*/
|
|
95
|
+
export function createOpenAiClient(options: OpenAiClientOptions): OpenAI {
|
|
96
|
+
// The SDK refuses to be constructed without a key, so an endpoint that needs none still gets one.
|
|
97
|
+
const apiKey = options.apiKey ?? 'no-key';
|
|
98
|
+
const proxyFetch = createProxyFetch(options.proxyUrl);
|
|
99
|
+
return options.apiVersion
|
|
100
|
+
? new AzureOpenAI({
|
|
101
|
+
apiKey, baseURL: options.baseURL, apiVersion: options.apiVersion, deployment: options.deployment, fetch: proxyFetch, defaultHeaders: options.headers
|
|
102
|
+
})
|
|
103
|
+
: new MistralFixedOpenAI({ apiKey, baseURL: options.baseURL, fetch: proxyFetch, defaultHeaders: options.headers });
|
|
104
|
+
}
|
|
105
|
+
|
|
74
106
|
export class OpenAiModel implements LanguageModel {
|
|
75
107
|
|
|
76
108
|
readonly vendor = 'openai';
|
|
@@ -119,15 +151,24 @@ export class OpenAiModel implements LanguageModel {
|
|
|
119
151
|
public serverTools?: ServerToolDescriptor[],
|
|
120
152
|
public serverSideCompactionSupport: boolean = false,
|
|
121
153
|
public serverSideCompactionEnabledByDefault: boolean = false,
|
|
122
|
-
public serverSideCompactionTokenThresholdByDefault?: number
|
|
154
|
+
public serverSideCompactionTokenThresholdByDefault?: number,
|
|
155
|
+
public headers?: Record<string, string>,
|
|
156
|
+
public released?: number
|
|
123
157
|
) { }
|
|
124
158
|
|
|
125
|
-
/**
|
|
159
|
+
/**
|
|
160
|
+
* Reasoning-level translation lives in {@link openAiReasoningFor}. On the Responses API, user-configured `reasoning`
|
|
161
|
+
* fields from the request settings (e.g. `summary`) are kept; the selected level still decides `effort`.
|
|
162
|
+
*/
|
|
126
163
|
protected getSettings(request: LanguageModelRequest, forResponseApi: boolean = false): Record<string, unknown> {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
164
|
+
const reasoning = openAiReasoningFor(request.reasoning?.level, forResponseApi, !!this.reasoningSupport);
|
|
165
|
+
const ours = reasoning.reasoning;
|
|
166
|
+
const theirs = request.settings?.reasoning;
|
|
167
|
+
if (isObject(ours) && isObject(theirs)) {
|
|
168
|
+
const effort = (ours as { effort?: string }).effort;
|
|
169
|
+
return { ...request.settings, reasoning: { ...ours, ...theirs, ...(effort !== undefined && { effort }) } };
|
|
170
|
+
}
|
|
171
|
+
return { ...request.settings, ...reasoning };
|
|
131
172
|
}
|
|
132
173
|
|
|
133
174
|
async request(request: UserRequest, cancellationToken?: CancellationToken): Promise<LanguageModelResponse> {
|
|
@@ -247,17 +288,14 @@ export class OpenAiModel implements LanguageModel {
|
|
|
247
288
|
throw new Error('Please provide OPENAI_API_KEY in preferences or via environment variable');
|
|
248
289
|
}
|
|
249
290
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
} else {
|
|
259
|
-
return new MistralFixedOpenAI({ apiKey: key, baseURL: this.url, fetch: proxyFetch });
|
|
260
|
-
}
|
|
291
|
+
return createOpenAiClient({
|
|
292
|
+
apiKey,
|
|
293
|
+
baseURL: this.url,
|
|
294
|
+
apiVersion: this.apiVersion(),
|
|
295
|
+
deployment: this.deployment,
|
|
296
|
+
proxyUrl: this.proxy,
|
|
297
|
+
headers: this.headers
|
|
298
|
+
});
|
|
261
299
|
}
|
|
262
300
|
|
|
263
301
|
/**
|