@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
|
@@ -18,11 +18,41 @@ import { expect } from 'chai';
|
|
|
18
18
|
import { OpenAiModelDescription } from '../common';
|
|
19
19
|
import { OpenAiLanguageModelsManagerImpl } from './openai-language-models-manager-impl';
|
|
20
20
|
import { OPENAI_SERVER_TOOLS, OPENAI_WEB_SEARCH } from './openai-server-tools';
|
|
21
|
+
import { APIConnectionError } from 'openai';
|
|
22
|
+
import { MockLogger } from '@theia/core/lib/common/test/mock-logger';
|
|
23
|
+
import { DiscoveredModel } from '@theia/ai-core';
|
|
24
|
+
import { TestModelDiscoveryFetcher } from '@theia/ai-core/lib/node/test/test-model-discovery-fetcher';
|
|
21
25
|
|
|
22
26
|
class TestableOpenAiLanguageModelsManagerImpl extends OpenAiLanguageModelsManagerImpl {
|
|
23
27
|
resolveServerToolsForTest(description: OpenAiModelDescription): typeof OPENAI_SERVER_TOOLS | undefined {
|
|
24
28
|
return this.resolveServerTools(description);
|
|
25
29
|
}
|
|
30
|
+
|
|
31
|
+
public stubbedModels: Array<{ id: string; created?: number }> = [];
|
|
32
|
+
public listCalls = 0;
|
|
33
|
+
/** Throw {@link failWith} for the first `failTimes` list calls, then return {@link stubbedModels}. */
|
|
34
|
+
public failTimes = 0;
|
|
35
|
+
public failWith: Error = new Error('boom');
|
|
36
|
+
/** The real discovery fetcher, snapshotting in memory and retrying without waiting. */
|
|
37
|
+
public readonly testFetcher = new TestModelDiscoveryFetcher();
|
|
38
|
+
protected override readonly discoveryFetcher = this.testFetcher;
|
|
39
|
+
|
|
40
|
+
/** In-memory stand-in for the on-disk snapshot. */
|
|
41
|
+
public get snapshot(): DiscoveredModel[] | undefined {
|
|
42
|
+
return this.testFetcher.snapshot;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
public set snapshot(models: DiscoveredModel[] | undefined) {
|
|
46
|
+
this.testFetcher.snapshot = models;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
protected override async listModels(_apiKey: string, _proxyUrl: string | undefined): Promise<Array<{ id: string; created?: number }>> {
|
|
50
|
+
this.listCalls++;
|
|
51
|
+
if (this.listCalls <= this.failTimes) {
|
|
52
|
+
throw this.failWith;
|
|
53
|
+
}
|
|
54
|
+
return this.stubbedModels;
|
|
55
|
+
}
|
|
26
56
|
}
|
|
27
57
|
|
|
28
58
|
function modelDescription(overrides: Partial<OpenAiModelDescription> = {}): OpenAiModelDescription {
|
|
@@ -55,3 +85,151 @@ describe('OpenAiLanguageModelsManagerImpl server tools', () => {
|
|
|
55
85
|
expect(manager.resolveServerToolsForTest(modelDescription())).to.equal(undefined);
|
|
56
86
|
});
|
|
57
87
|
});
|
|
88
|
+
|
|
89
|
+
describe('OpenAiLanguageModelsManagerImpl - fetchAvailableModels', () => {
|
|
90
|
+
let manager: TestableOpenAiLanguageModelsManagerImpl;
|
|
91
|
+
|
|
92
|
+
beforeEach(() => {
|
|
93
|
+
manager = new TestableOpenAiLanguageModelsManagerImpl();
|
|
94
|
+
(manager as unknown as { logger: MockLogger }).logger = new MockLogger();
|
|
95
|
+
manager.setApiKey('key');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('returns an empty result and skips the network when no API key is set', async () => {
|
|
99
|
+
const previous = process.env.OPENAI_API_KEY;
|
|
100
|
+
delete process.env.OPENAI_API_KEY;
|
|
101
|
+
try {
|
|
102
|
+
manager.setApiKey(undefined);
|
|
103
|
+
expect(await manager.fetchAvailableModels()).to.deep.equal({ models: [], fromCache: false });
|
|
104
|
+
expect(manager.listCalls).to.equal(0);
|
|
105
|
+
} finally {
|
|
106
|
+
if (previous !== undefined) { process.env.OPENAI_API_KEY = previous; }
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('keeps the text-chat families and drops the other model types the endpoint lists', async () => {
|
|
111
|
+
manager.stubbedModels = [
|
|
112
|
+
{ id: 'gpt-5.5' },
|
|
113
|
+
{ id: 'chatgpt-5-latest' },
|
|
114
|
+
{ id: 'o3-mini' },
|
|
115
|
+
{ id: 'gpt-4o-audio-preview' },
|
|
116
|
+
{ id: 'gpt-image-1' },
|
|
117
|
+
{ id: 'text-embedding-3-large' },
|
|
118
|
+
{ id: 'omni-moderation-latest' },
|
|
119
|
+
{ id: 'whisper-1' },
|
|
120
|
+
{ id: 'computer-use-preview' },
|
|
121
|
+
{ id: 'gpt-4o-search-preview' },
|
|
122
|
+
// Multimodal, so it speaks the realtime/live API rather than chat completions.
|
|
123
|
+
{ id: 'gpt-live-1' },
|
|
124
|
+
// Caught by the same `search` term, and rightly so: it speaks the responses API, not this one.
|
|
125
|
+
{ id: 'o3-deep-research' },
|
|
126
|
+
{ id: 'gpt-3.5-turbo-instruct' },
|
|
127
|
+
{ id: 'gpt-5.5' }
|
|
128
|
+
];
|
|
129
|
+
const result = await manager.fetchAvailableModels();
|
|
130
|
+
expect(result.fromCache).to.equal(false);
|
|
131
|
+
expect(result.models.map(model => model.id)).to.deep.equal(['gpt-5.5', 'chatgpt-5-latest', 'o3-mini']);
|
|
132
|
+
expect(manager.snapshot).to.deep.equal(result.models);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('recognises the legacy year-less snapshots as releases of one model', async () => {
|
|
136
|
+
manager.stubbedModels = [
|
|
137
|
+
{ id: 'gpt-4-0613', created: 1_686_000_000 },
|
|
138
|
+
{ id: 'gpt-4-0125', created: 1_706_000_000 },
|
|
139
|
+
{ id: 'gpt-4-32k-0613', created: 1_686_000_000 }
|
|
140
|
+
];
|
|
141
|
+
const result = await manager.fetchAvailableModels();
|
|
142
|
+
// Two aliases for the two models, then the snapshots they were derived from.
|
|
143
|
+
expect(result.models.map(model => model.id)).to.deep.equal([
|
|
144
|
+
'gpt-4',
|
|
145
|
+
'gpt-4-32k',
|
|
146
|
+
'gpt-4-0613',
|
|
147
|
+
'gpt-4-0125',
|
|
148
|
+
'gpt-4-32k-0613'
|
|
149
|
+
]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('adds the undated alias of each release-pinned model, keeping the releases themselves', async () => {
|
|
153
|
+
manager.stubbedModels = [
|
|
154
|
+
{ id: 'gpt-5.6-sol-2026-04-01' },
|
|
155
|
+
{ id: 'gpt-5.6-sol-2025-11-20' },
|
|
156
|
+
{ id: 'gpt-5.6-luna' }
|
|
157
|
+
];
|
|
158
|
+
const result = await manager.fetchAvailableModels();
|
|
159
|
+
expect(result.models.map(model => model.id)).to.deep.equal([
|
|
160
|
+
'gpt-5.6-sol',
|
|
161
|
+
'gpt-5.6-sol-2026-04-01',
|
|
162
|
+
'gpt-5.6-sol-2025-11-20',
|
|
163
|
+
'gpt-5.6-luna'
|
|
164
|
+
]);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('reports the creation timestamp in milliseconds', async () => {
|
|
168
|
+
manager.stubbedModels = [{ id: 'gpt-5.5', created: 1_700_000_000 }];
|
|
169
|
+
const result = await manager.fetchAvailableModels();
|
|
170
|
+
expect(result.models[0].released).to.equal(1_700_000_000_000);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('retries transient connection errors and then succeeds', async () => {
|
|
174
|
+
manager.stubbedModels = [{ id: 'gpt-5.5' }];
|
|
175
|
+
manager.failTimes = 2;
|
|
176
|
+
manager.failWith = new APIConnectionError({ message: 'network down' });
|
|
177
|
+
const result = await manager.fetchAvailableModels();
|
|
178
|
+
expect(result.models.map(model => model.id)).to.deep.equal(['gpt-5.5']);
|
|
179
|
+
expect(manager.listCalls).to.equal(3);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('falls back to the cached snapshot when the fetch keeps failing', async () => {
|
|
183
|
+
manager.snapshot = [{ id: 'gpt-5.5' }];
|
|
184
|
+
manager.failTimes = 99;
|
|
185
|
+
manager.failWith = new APIConnectionError({ message: 'still down' });
|
|
186
|
+
const result = await manager.fetchAvailableModels();
|
|
187
|
+
expect(result).to.deep.equal({ models: [{ id: 'gpt-5.5' }], fromCache: true, error: 'still down' });
|
|
188
|
+
expect(manager.listCalls).to.equal(3);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('does not retry auth errors and throws without a snapshot', async () => {
|
|
192
|
+
manager.failTimes = 99;
|
|
193
|
+
manager.failWith = new Error('401 unauthorized');
|
|
194
|
+
let threw = false;
|
|
195
|
+
try {
|
|
196
|
+
await manager.fetchAvailableModels();
|
|
197
|
+
} catch (error) {
|
|
198
|
+
threw = true;
|
|
199
|
+
expect((error as Error).message).to.equal('401 unauthorized');
|
|
200
|
+
}
|
|
201
|
+
expect(threw).to.be.true;
|
|
202
|
+
expect(manager.listCalls).to.equal(1);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe('OpenAiLanguageModelsManagerImpl - environment API key consent', () => {
|
|
207
|
+
|
|
208
|
+
it('leaves an environment key unused until it is allowed, wherever a key would be read', async () => {
|
|
209
|
+
const previous = { OPENAI_API_KEY: process.env.OPENAI_API_KEY };
|
|
210
|
+
delete process.env.OPENAI_API_KEY;
|
|
211
|
+
process.env.OPENAI_API_KEY = 'from-the-environment';
|
|
212
|
+
try {
|
|
213
|
+
const manager = new TestableOpenAiLanguageModelsManagerImpl();
|
|
214
|
+
// The gate sits on the key, so a custom endpoint or a configured model cannot reach past it either.
|
|
215
|
+
expect(manager.apiKey).to.equal(undefined);
|
|
216
|
+
// Discovery still needs to know the key is there, in order to ask for it.
|
|
217
|
+
expect(await manager.getApiKeySource()).to.equal('environment');
|
|
218
|
+
|
|
219
|
+
manager.setAllowEnvironmentApiKey(true);
|
|
220
|
+
expect(manager.apiKey).to.equal('from-the-environment');
|
|
221
|
+
|
|
222
|
+
// Withdrawing the consent stops it being used at once.
|
|
223
|
+
manager.setAllowEnvironmentApiKey(false);
|
|
224
|
+
expect(manager.apiKey).to.equal(undefined);
|
|
225
|
+
} finally {
|
|
226
|
+
if (previous.OPENAI_API_KEY !== undefined) { process.env.OPENAI_API_KEY = previous.OPENAI_API_KEY; } else { delete process.env.OPENAI_API_KEY; }
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('uses a key set in the preferences whatever the environment says', () => {
|
|
231
|
+
const manager = new TestableOpenAiLanguageModelsManagerImpl();
|
|
232
|
+
manager.setApiKey('from-the-preference');
|
|
233
|
+
expect(manager.apiKey).to.equal('from-the-preference');
|
|
234
|
+
});
|
|
235
|
+
});
|
|
@@ -14,16 +14,28 @@
|
|
|
14
14
|
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
|
15
15
|
// *****************************************************************************
|
|
16
16
|
|
|
17
|
-
import {
|
|
18
|
-
|
|
17
|
+
import {
|
|
18
|
+
ApiKeySource, DiscoveredModel, DiscoveredModels, LanguageModelRegistry, LanguageModelStatus, ModelDiscoveryResult, ReasoningSupport
|
|
19
|
+
} from '@theia/ai-core';
|
|
20
|
+
import { getProxyUrl, ModelDiscoveryFetcher } from '@theia/ai-core/lib/node';
|
|
19
21
|
import { inject, injectable, named } from '@theia/core/shared/inversify';
|
|
20
|
-
import {
|
|
22
|
+
import { APIConnectionError } from 'openai';
|
|
23
|
+
import { createOpenAiClient, DeveloperMessageSettings, OpenAiModel, OpenAiModelUtils } from './openai-language-model';
|
|
21
24
|
import { OpenAiResponseApiUtils } from './openai-response-api-utils';
|
|
22
25
|
import { getOpenAiModelDefaults } from './openai-model-defaults';
|
|
23
26
|
import { OpenAiLanguageModelsManager, OpenAiModelDescription } from '../common';
|
|
24
27
|
import { ILogger } from '@theia/core';
|
|
25
28
|
import { OPENAI_SERVER_TOOLS } from './openai-server-tools';
|
|
26
29
|
|
|
30
|
+
const OPENAI_SNAPSHOT_FILE = 'openai-models.json';
|
|
31
|
+
|
|
32
|
+
/** The part of an OpenAI `/v1/models` entry this manager reads. */
|
|
33
|
+
interface ListedOpenAiModel {
|
|
34
|
+
id: string;
|
|
35
|
+
/** Creation time in seconds since the epoch. */
|
|
36
|
+
created?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
27
39
|
interface ResolvedModelMetadata {
|
|
28
40
|
maxInputTokens?: number;
|
|
29
41
|
reasoningSupport?: ReasoningSupport;
|
|
@@ -46,14 +58,94 @@ export class OpenAiLanguageModelsManagerImpl implements OpenAiLanguageModelsMana
|
|
|
46
58
|
protected readonly logger: ILogger;
|
|
47
59
|
|
|
48
60
|
protected _apiKey: string | undefined;
|
|
61
|
+
/**
|
|
62
|
+
* Whether a key found in the environment may be used. Withheld until the user confirms it, so the
|
|
63
|
+
* gate sits on the key itself: every path that reaches for one — discovery, a custom endpoint, a
|
|
64
|
+
* manually configured model — is covered, and revoking the consent takes effect at once.
|
|
65
|
+
*/
|
|
66
|
+
protected _allowEnvironmentApiKey = false;
|
|
49
67
|
protected _apiVersion: string | undefined;
|
|
50
68
|
protected _proxyUrl: string | undefined;
|
|
51
69
|
|
|
52
70
|
@inject(LanguageModelRegistry)
|
|
53
71
|
protected readonly languageModelRegistry: LanguageModelRegistry;
|
|
54
72
|
|
|
73
|
+
@inject(ModelDiscoveryFetcher)
|
|
74
|
+
protected readonly discoveryFetcher: ModelDiscoveryFetcher;
|
|
75
|
+
|
|
55
76
|
get apiKey(): string | undefined {
|
|
56
|
-
return this._apiKey ?? process.env.OPENAI_API_KEY;
|
|
77
|
+
return this._apiKey ?? (this._allowEnvironmentApiKey ? process.env.OPENAI_API_KEY : undefined);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async getApiKeySource(): Promise<ApiKeySource> {
|
|
81
|
+
if (this._apiKey) {
|
|
82
|
+
return 'preference';
|
|
83
|
+
}
|
|
84
|
+
if (process.env.OPENAI_API_KEY) {
|
|
85
|
+
return 'environment';
|
|
86
|
+
}
|
|
87
|
+
return 'none';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async fetchAvailableModels(): Promise<ModelDiscoveryResult> {
|
|
91
|
+
const apiKey = this.apiKey;
|
|
92
|
+
if (!apiKey) {
|
|
93
|
+
return { models: [], fromCache: false };
|
|
94
|
+
}
|
|
95
|
+
const proxyUrl = getProxyUrl('https://api.openai.com', this._proxyUrl);
|
|
96
|
+
return this.discoveryFetcher.fetch({
|
|
97
|
+
snapshotFile: OPENAI_SNAPSHOT_FILE,
|
|
98
|
+
providerLabel: 'OpenAI',
|
|
99
|
+
listModels: async () => this.toDiscoveredModels(await this.listModels(apiKey, proxyUrl)),
|
|
100
|
+
// Retry only transient connection errors; auth/HTTP errors fail fast.
|
|
101
|
+
isRetryable: error => error instanceof APIConnectionError
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Maps the endpoint's entries onto {@link DiscoveredModel}s and adds the undated alias of every
|
|
107
|
+
* release-pinned id. The endpoint reports no display name or description — only the id, the
|
|
108
|
+
* owner and a creation timestamp — so a discovered OpenAI model carries no label.
|
|
109
|
+
*/
|
|
110
|
+
protected toDiscoveredModels(models: ListedOpenAiModel[]): DiscoveredModel[] {
|
|
111
|
+
const byId = new Map<string, DiscoveredModel>();
|
|
112
|
+
for (const model of models) {
|
|
113
|
+
// OpenAI's /v1/models lists every model type (embeddings, audio, image, …) with no capability
|
|
114
|
+
// metadata, so we heuristically keep the text-chat families. Custom endpoints cover the rest.
|
|
115
|
+
if (this.isChatModelId(model.id) && !byId.has(model.id)) {
|
|
116
|
+
// `created` is in seconds; DiscoveredModel.released is in milliseconds.
|
|
117
|
+
byId.set(model.id, { id: model.id, released: model.created === undefined ? undefined : model.created * 1000 });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return DiscoveredModels.withUndatedAliases([...byId.values()]);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Heuristic for the text-chat models among everything `/v1/models` reports: the `gpt-*`,
|
|
125
|
+
* `chatgpt-*` and `o1`/`o3`/`o4`-style families, minus the variants that speak a different API
|
|
126
|
+
* than chat completions: audio, realtime, live, transcription, speech, image, embeddings,
|
|
127
|
+
* moderation, the search and computer-use tool endpoints, and the legacy `-instruct` completion
|
|
128
|
+
* models.
|
|
129
|
+
*
|
|
130
|
+
* The terms match anywhere in the id, so a family that carries one in a longer word goes with it
|
|
131
|
+
* (`o3-deep-research`, which speaks the responses API and not this one). Anything this drops or
|
|
132
|
+
* misses can still be configured as a custom endpoint.
|
|
133
|
+
*/
|
|
134
|
+
protected isChatModelId(id: string): boolean {
|
|
135
|
+
if (!/^(gpt|chatgpt|o\d)/.test(id)) {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
return !/(audio|realtime|-live|transcribe|tts|image|embedding|moderation|search|computer-use|-instruct)/.test(id);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Iterates the (auto-paginated) `/v1/models` endpoint. Overridable for testing. */
|
|
142
|
+
protected async listModels(apiKey: string, proxyUrl: string | undefined): Promise<ListedOpenAiModel[]> {
|
|
143
|
+
const openai = createOpenAiClient({ apiKey, proxyUrl });
|
|
144
|
+
const models: ListedOpenAiModel[] = [];
|
|
145
|
+
for await (const model of openai.models.list()) {
|
|
146
|
+
models.push(model);
|
|
147
|
+
}
|
|
148
|
+
return models;
|
|
57
149
|
}
|
|
58
150
|
|
|
59
151
|
get apiVersion(): string | undefined {
|
|
@@ -122,7 +214,9 @@ export class OpenAiLanguageModelsManagerImpl implements OpenAiLanguageModelsMana
|
|
|
122
214
|
serverTools,
|
|
123
215
|
serverSideCompactionSupport: metadata.serverSideCompactionSupport,
|
|
124
216
|
serverSideCompactionEnabledByDefault: modelDescription.serverSideCompactionEnabledByDefault ?? false,
|
|
125
|
-
serverSideCompactionTokenThresholdByDefault: modelDescription.serverSideCompactionTokenThresholdByDefault
|
|
217
|
+
serverSideCompactionTokenThresholdByDefault: modelDescription.serverSideCompactionTokenThresholdByDefault,
|
|
218
|
+
headers: modelDescription.headers,
|
|
219
|
+
released: modelDescription.released
|
|
126
220
|
});
|
|
127
221
|
} else {
|
|
128
222
|
this.languageModelRegistry.addLanguageModels([
|
|
@@ -147,7 +241,9 @@ export class OpenAiLanguageModelsManagerImpl implements OpenAiLanguageModelsMana
|
|
|
147
241
|
serverTools,
|
|
148
242
|
metadata.serverSideCompactionSupport,
|
|
149
243
|
modelDescription.serverSideCompactionEnabledByDefault ?? false,
|
|
150
|
-
modelDescription.serverSideCompactionTokenThresholdByDefault
|
|
244
|
+
modelDescription.serverSideCompactionTokenThresholdByDefault,
|
|
245
|
+
modelDescription.headers,
|
|
246
|
+
modelDescription.released
|
|
151
247
|
)
|
|
152
248
|
]);
|
|
153
249
|
}
|
|
@@ -181,6 +277,10 @@ export class OpenAiLanguageModelsManagerImpl implements OpenAiLanguageModelsMana
|
|
|
181
277
|
this.languageModelRegistry.removeLanguageModels(modelIds);
|
|
182
278
|
}
|
|
183
279
|
|
|
280
|
+
setAllowEnvironmentApiKey(allowed: boolean): void {
|
|
281
|
+
this._allowEnvironmentApiKey = allowed;
|
|
282
|
+
}
|
|
283
|
+
|
|
184
284
|
setApiKey(apiKey: string | undefined): void {
|
|
185
285
|
if (apiKey) {
|
|
186
286
|
this._apiKey = apiKey;
|
|
@@ -22,6 +22,16 @@ describe('getOpenAiModelDefaults', () => {
|
|
|
22
22
|
expect(getOpenAiModelDefaults('totally-made-up-model')).to.deep.equal({});
|
|
23
23
|
});
|
|
24
24
|
|
|
25
|
+
describe('GPT-6 Astra', () => {
|
|
26
|
+
it('matches gpt-6-astra at 1,050,000 with reasoning that drops `minimal`', () => {
|
|
27
|
+
const d = getOpenAiModelDefaults('gpt-6-astra');
|
|
28
|
+
expect(d.contextWindow).to.equal(1_050_000);
|
|
29
|
+
expect(d.reasoningSupport?.supportedLevels).to.not.include('minimal');
|
|
30
|
+
expect(d.reasoningSupport?.supportedLevels).to.include('high');
|
|
31
|
+
expect(d.reasoningSupport?.defaultLevel).to.equal('auto');
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
25
35
|
describe('GPT-5.6', () => {
|
|
26
36
|
it('matches the sol, terra, and luna tiers at 1,050,000 with GPT-5 reasoning', () => {
|
|
27
37
|
for (const id of ['gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna']) {
|
|
@@ -41,12 +41,20 @@ const O_SERIES_REASONING_SUPPORT: ReasoningSupport = {
|
|
|
41
41
|
defaultLevel: 'auto'
|
|
42
42
|
};
|
|
43
43
|
|
|
44
|
+
// GPT-6 Astra reasons at every setting: it dropped the `none`/`minimal` efforts of the GPT-5 family.
|
|
45
|
+
// Its `xhigh`/`max` efforts have no representation in ReasoningLevel, so we expose the mappable subset.
|
|
46
|
+
const GPT6_REASONING_SUPPORT: ReasoningSupport = {
|
|
47
|
+
supportedLevels: ['low', 'medium', 'high', 'auto'],
|
|
48
|
+
defaultLevel: 'auto'
|
|
49
|
+
};
|
|
50
|
+
|
|
44
51
|
/**
|
|
45
52
|
* First matching prefix wins, so more specific prefixes must come before broader ones
|
|
46
53
|
* (e.g. `gpt-5.4-mini` before `gpt-5.4`). Snapshots inherit their family value
|
|
47
54
|
* (e.g. `gpt-4o-2024-08-06` matches `gpt-4o`).
|
|
48
55
|
*/
|
|
49
56
|
const OPENAI_MODEL_FAMILIES: ReadonlyArray<readonly [prefix: string, defaults: OpenAiModelDefaults]> = [
|
|
57
|
+
['gpt-6', { contextWindow: 1_050_000, reasoningSupport: GPT6_REASONING_SUPPORT }],
|
|
50
58
|
['gpt-5.6', { contextWindow: 1_050_000, reasoningSupport: GPT5_REASONING_SUPPORT }],
|
|
51
59
|
['gpt-5.5', { contextWindow: 1_050_000, reasoningSupport: GPT5_REASONING_SUPPORT }],
|
|
52
60
|
['gpt-5.4-mini', { contextWindow: 400_000, reasoningSupport: GPT5_REASONING_SUPPORT }],
|
|
@@ -21,8 +21,7 @@ import { ReasoningLevel } from '@theia/ai-core';
|
|
|
21
21
|
* Returns `{}` when reasoning is not requested, unsupported, or disabled — so the caller
|
|
22
22
|
* can spread it unconditionally.
|
|
23
23
|
*
|
|
24
|
-
* @param forResponseApi `true` for the Responses API (`reasoning: { effort }
|
|
25
|
-
* `false` for Chat Completions (`reasoning_effort`, `low`|`medium`|`high` only).
|
|
24
|
+
* @param forResponseApi `true` for the Responses API (`reasoning: { effort, summary }`), `false` for Chat Completions (`reasoning_effort`).
|
|
26
25
|
* @param supportsReasoning `false` for models without reasoning support — returns `{}`.
|
|
27
26
|
*/
|
|
28
27
|
export function openAiReasoningFor(
|
|
@@ -40,13 +39,11 @@ export function openAiReasoningFor(
|
|
|
40
39
|
level === 'medium' ? 'medium' :
|
|
41
40
|
level === 'high' ? 'high' :
|
|
42
41
|
undefined;
|
|
43
|
-
|
|
42
|
+
// Summaries are opt-in on the Responses API: without `summary` the model still reasons, but nothing is streamed to show.
|
|
43
|
+
return { reasoning: { ...(responsesEffort ? { effort: responsesEffort } : {}), summary: 'auto' } };
|
|
44
44
|
}
|
|
45
|
-
// Chat Completions
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
level === 'medium' ? 'medium' :
|
|
49
|
-
level === 'high' ? 'high' :
|
|
50
|
-
undefined;
|
|
45
|
+
// Chat Completions takes the same effort values, `minimal` included. Models that reject it (e.g. o-series)
|
|
46
|
+
// leave it out of their `supportedLevels`, and the level is clamped to those before it gets here.
|
|
47
|
+
const chatEffort = level === 'minimal' || level === 'low' || level === 'medium' || level === 'high' ? level : undefined;
|
|
51
48
|
return chatEffort ? { reasoning_effort: chatEffort } : {};
|
|
52
49
|
}
|
|
@@ -16,10 +16,12 @@
|
|
|
16
16
|
|
|
17
17
|
import { expect } from 'chai';
|
|
18
18
|
import {
|
|
19
|
-
CompactionMessage, isCompactionResponsePart, isServerToolCallResponsePart,
|
|
19
|
+
CompactionMessage, isCompactionResponsePart, isServerToolCallResponsePart, isTextResponsePart, isThinkingResponsePart, isUsageResponsePart,
|
|
20
|
+
LanguageModelMessage, LanguageModelResponse, LanguageModelStreamResponsePart, UserRequest
|
|
20
21
|
} from '@theia/ai-core';
|
|
21
22
|
import { OpenAiModelUtils } from './openai-language-model';
|
|
22
23
|
import { MockLogger } from '@theia/core/lib/common/test/mock-logger';
|
|
24
|
+
import { OpenAI } from 'openai';
|
|
23
25
|
import { OPENAI_FUNCTION_CALL_REASONING_DATA_KEY, OpenAiResponseApiUtils } from './openai-response-api-utils';
|
|
24
26
|
import { OPENAI_WEB_SEARCH, OPENAI_WEB_SEARCH_REPLAY_DATA_KEY } from './openai-server-tools';
|
|
25
27
|
|
|
@@ -555,6 +557,187 @@ describe('OpenAiResponseApiUtils', () => {
|
|
|
555
557
|
}]);
|
|
556
558
|
});
|
|
557
559
|
|
|
560
|
+
describe('reasoning summaries', () => {
|
|
561
|
+
// Two summary parts of one reasoning item, as the Responses API streams them with `reasoning.summary` set.
|
|
562
|
+
const summaryEvents = [
|
|
563
|
+
{ type: 'response.reasoning_summary_part.added', item_id: 'rs-1', output_index: 0, summary_index: 0, part: { type: 'summary_text', text: '' } },
|
|
564
|
+
{ type: 'response.reasoning_summary_text.delta', item_id: 'rs-1', output_index: 0, summary_index: 0, delta: 'Weighing ' },
|
|
565
|
+
{ type: 'response.reasoning_summary_text.delta', item_id: 'rs-1', output_index: 0, summary_index: 0, delta: 'options' },
|
|
566
|
+
{ type: 'response.reasoning_summary_part.added', item_id: 'rs-1', output_index: 0, summary_index: 1, part: { type: 'summary_text', text: '' } },
|
|
567
|
+
{ type: 'response.reasoning_summary_text.delta', item_id: 'rs-1', output_index: 0, summary_index: 1, delta: 'Deciding' }
|
|
568
|
+
];
|
|
569
|
+
|
|
570
|
+
async function drain(response: LanguageModelResponse): Promise<LanguageModelStreamResponsePart[]> {
|
|
571
|
+
const parts: LanguageModelStreamResponsePart[] = [];
|
|
572
|
+
if ('stream' in response) {
|
|
573
|
+
for await (const part of response.stream) {
|
|
574
|
+
parts.push(part);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
return parts;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function thoughts(parts: LanguageModelStreamResponsePart[]): string {
|
|
581
|
+
return parts.filter(isThinkingResponsePart).map(part => part.thought).join('');
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
it('streams reasoning summaries as thoughts, separating summary parts', async () => {
|
|
585
|
+
const openai = {
|
|
586
|
+
responses: {
|
|
587
|
+
stream: () => toStream([...summaryEvents, { type: 'response.output_text.delta', delta: 'Answer' }])
|
|
588
|
+
}
|
|
589
|
+
};
|
|
590
|
+
const request: UserRequest = {
|
|
591
|
+
sessionId: 'session-1',
|
|
592
|
+
requestId: 'request-1',
|
|
593
|
+
messages: [{ actor: 'user', type: 'text', text: 'hello' }]
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
const parts = await drain(await utils.handleRequest(
|
|
597
|
+
openai as never, request, {}, 'gpt-5', new OpenAiModelUtils(), 'developer',
|
|
598
|
+
{ maxChatCompletions: 3 }, 'openai/gpt-5', true
|
|
599
|
+
));
|
|
600
|
+
|
|
601
|
+
expect(thoughts(parts)).to.equal('Weighing options\n\nDeciding');
|
|
602
|
+
expect(parts.filter(isTextResponsePart).map(part => part.content).join('')).to.equal('Answer');
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
it('streams reasoning summaries as thoughts while tool calling', async () => {
|
|
606
|
+
const streams = [
|
|
607
|
+
[
|
|
608
|
+
...summaryEvents,
|
|
609
|
+
{
|
|
610
|
+
type: 'response.output_item.added',
|
|
611
|
+
item: { id: 'item-1', call_id: 'call-1', type: 'function_call', name: 'lookup', arguments: '{}' }
|
|
612
|
+
}
|
|
613
|
+
],
|
|
614
|
+
[{ type: 'response.output_text.delta', delta: 'done' }]
|
|
615
|
+
];
|
|
616
|
+
const openai = {
|
|
617
|
+
responses: {
|
|
618
|
+
stream: () => toStream(streams.shift() ?? [])
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
const request: UserRequest = {
|
|
622
|
+
sessionId: 'session-1',
|
|
623
|
+
requestId: 'request-1',
|
|
624
|
+
messages: [{ actor: 'user', type: 'text', text: 'hello' }],
|
|
625
|
+
tools: [{
|
|
626
|
+
id: 'lookup',
|
|
627
|
+
name: 'lookup',
|
|
628
|
+
parameters: { type: 'object', properties: {} },
|
|
629
|
+
handler: async () => 'result'
|
|
630
|
+
}]
|
|
631
|
+
};
|
|
632
|
+
|
|
633
|
+
const parts = await drain(await utils.handleRequest(
|
|
634
|
+
openai as never, request, {}, 'gpt-5', new OpenAiModelUtils(), 'developer',
|
|
635
|
+
{ maxChatCompletions: 3 }, 'openai/gpt-5', true
|
|
636
|
+
));
|
|
637
|
+
|
|
638
|
+
expect(thoughts(parts)).to.equal('Weighing options\n\nDeciding');
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
describe('unverified organizations', () => {
|
|
642
|
+
const summarySettings = { reasoning: { effort: 'medium', summary: 'auto' } };
|
|
643
|
+
const request: UserRequest = {
|
|
644
|
+
sessionId: 'session-1',
|
|
645
|
+
requestId: 'request-1',
|
|
646
|
+
messages: [{ actor: 'user', type: 'text', text: 'hello' }]
|
|
647
|
+
};
|
|
648
|
+
|
|
649
|
+
function badRequest(param: string): Error {
|
|
650
|
+
const message = `400 Your organization must be verified to generate reasoning summaries (param: ${param})`;
|
|
651
|
+
return new OpenAI.BadRequestError(400, { message, param }, message, new Headers());
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
async function* rejectingStream(error: Error): AsyncIterable<unknown> {
|
|
655
|
+
throw error;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function summaryOf(params: { reasoning?: { summary?: string } }): string | undefined {
|
|
659
|
+
return params.reasoning?.summary;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
it('retries a stream without the summary and omits it for later requests', async () => {
|
|
663
|
+
const sent: { reasoning?: { effort?: string; summary?: string } }[] = [];
|
|
664
|
+
const openai = {
|
|
665
|
+
responses: {
|
|
666
|
+
stream: (params: { reasoning?: { summary?: string } }) => {
|
|
667
|
+
sent.push(params);
|
|
668
|
+
return summaryOf(params)
|
|
669
|
+
? rejectingStream(badRequest('reasoning.summary'))
|
|
670
|
+
: toStream([{ type: 'response.output_text.delta', delta: 'Answer' }]);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
};
|
|
674
|
+
const send = async () => drain(await utils.handleRequest(
|
|
675
|
+
openai as never, request, summarySettings, 'gpt-5', new OpenAiModelUtils(), 'developer',
|
|
676
|
+
{ maxChatCompletions: 3 }, 'openai/gpt-5', true
|
|
677
|
+
));
|
|
678
|
+
|
|
679
|
+
const parts = await send();
|
|
680
|
+
await send();
|
|
681
|
+
|
|
682
|
+
expect(parts.filter(isTextResponsePart).map(part => part.content).join('')).to.equal('Answer');
|
|
683
|
+
expect(sent.map(summaryOf)).to.deep.equal(['auto', undefined, undefined]);
|
|
684
|
+
expect(sent[1].reasoning?.effort).to.equal('medium');
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
it('retries a non-streaming tool-calling request without the summary', async () => {
|
|
688
|
+
const sent: { reasoning?: { summary?: string } }[] = [];
|
|
689
|
+
const openai = {
|
|
690
|
+
responses: {
|
|
691
|
+
create: async (params: { reasoning?: { summary?: string } }) => {
|
|
692
|
+
sent.push(params);
|
|
693
|
+
if (summaryOf(params)) {
|
|
694
|
+
throw badRequest('reasoning.summary');
|
|
695
|
+
}
|
|
696
|
+
return { output_text: 'done', output: [] };
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
};
|
|
700
|
+
const toolRequest: UserRequest = {
|
|
701
|
+
...request,
|
|
702
|
+
tools: [{ id: 'lookup', name: 'lookup', parameters: { type: 'object', properties: {} }, handler: async () => 'result' }]
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
const parts = await drain(await utils.handleRequest(
|
|
706
|
+
openai as never, toolRequest, summarySettings, 'gpt-5', new OpenAiModelUtils(), 'developer',
|
|
707
|
+
{ maxChatCompletions: 3 }, 'openai/gpt-5', false
|
|
708
|
+
));
|
|
709
|
+
|
|
710
|
+
expect(parts.filter(isTextResponsePart).map(part => part.content).join('')).to.equal('done');
|
|
711
|
+
expect(sent.map(summaryOf)).to.deep.equal(['auto', undefined]);
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
it('does not retry other bad requests', async () => {
|
|
715
|
+
let calls = 0;
|
|
716
|
+
const openai = {
|
|
717
|
+
responses: {
|
|
718
|
+
create: async () => {
|
|
719
|
+
calls++;
|
|
720
|
+
throw badRequest('reasoning.effort');
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
let error: unknown;
|
|
726
|
+
try {
|
|
727
|
+
await utils.handleRequest(
|
|
728
|
+
openai as never, request, summarySettings, 'gpt-5', new OpenAiModelUtils(), 'developer',
|
|
729
|
+
{ maxChatCompletions: 3 }, 'openai/gpt-5', false
|
|
730
|
+
);
|
|
731
|
+
} catch (e) {
|
|
732
|
+
error = e;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
expect(error).to.be.instanceOf(OpenAI.BadRequestError);
|
|
736
|
+
expect(calls).to.equal(1);
|
|
737
|
+
});
|
|
738
|
+
});
|
|
739
|
+
});
|
|
740
|
+
|
|
558
741
|
describe('processMessages server-side compaction replay', () => {
|
|
559
742
|
|
|
560
743
|
function userMessage(text: string): LanguageModelMessage {
|