@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.
Files changed (50) hide show
  1. package/lib/browser/openai-frontend-application-contribution.d.ts +18 -10
  2. package/lib/browser/openai-frontend-application-contribution.d.ts.map +1 -1
  3. package/lib/browser/openai-frontend-application-contribution.js +68 -64
  4. package/lib/browser/openai-frontend-application-contribution.js.map +1 -1
  5. package/lib/common/openai-language-models-manager.d.ts +20 -1
  6. package/lib/common/openai-language-models-manager.d.ts.map +1 -1
  7. package/lib/common/openai-preferences.d.ts +2 -1
  8. package/lib/common/openai-preferences.d.ts.map +1 -1
  9. package/lib/common/openai-preferences.js +23 -13
  10. package/lib/common/openai-preferences.js.map +1 -1
  11. package/lib/node/openai-language-model.d.ts +26 -2
  12. package/lib/node/openai-language-model.d.ts.map +1 -1
  13. package/lib/node/openai-language-model.js +44 -18
  14. package/lib/node/openai-language-model.js.map +1 -1
  15. package/lib/node/openai-language-model.spec.js +55 -8
  16. package/lib/node/openai-language-model.spec.js.map +1 -1
  17. package/lib/node/openai-language-models-manager-impl.d.ts +38 -1
  18. package/lib/node/openai-language-models-manager-impl.d.ts.map +1 -1
  19. package/lib/node/openai-language-models-manager-impl.js +88 -3
  20. package/lib/node/openai-language-models-manager-impl.js.map +1 -1
  21. package/lib/node/openai-language-models-manager-impl.spec.js +171 -0
  22. package/lib/node/openai-language-models-manager-impl.spec.js.map +1 -1
  23. package/lib/node/openai-model-defaults.d.ts.map +1 -1
  24. package/lib/node/openai-model-defaults.js +7 -0
  25. package/lib/node/openai-model-defaults.js.map +1 -1
  26. package/lib/node/openai-model-defaults.spec.js +9 -0
  27. package/lib/node/openai-model-defaults.spec.js.map +1 -1
  28. package/lib/node/openai-reasoning.d.ts +1 -2
  29. package/lib/node/openai-reasoning.d.ts.map +1 -1
  30. package/lib/node/openai-reasoning.js +6 -8
  31. package/lib/node/openai-reasoning.js.map +1 -1
  32. package/lib/node/openai-response-api-utils.d.ts +25 -1
  33. package/lib/node/openai-response-api-utils.d.ts.map +1 -1
  34. package/lib/node/openai-response-api-utils.js +104 -12
  35. package/lib/node/openai-response-api-utils.js.map +1 -1
  36. package/lib/node/openai-response-api-utils.spec.js +146 -0
  37. package/lib/node/openai-response-api-utils.spec.js.map +1 -1
  38. package/package.json +7 -7
  39. package/src/browser/openai-frontend-application-contribution.ts +82 -69
  40. package/src/common/openai-language-models-manager.ts +21 -2
  41. package/src/common/openai-preferences.ts +25 -12
  42. package/src/node/openai-language-model.spec.ts +63 -9
  43. package/src/node/openai-language-model.ts +58 -20
  44. package/src/node/openai-language-models-manager-impl.spec.ts +178 -0
  45. package/src/node/openai-language-models-manager-impl.ts +106 -6
  46. package/src/node/openai-model-defaults.spec.ts +10 -0
  47. package/src/node/openai-model-defaults.ts +8 -0
  48. package/src/node/openai-reasoning.ts +6 -9
  49. package/src/node/openai-response-api-utils.spec.ts +184 -1
  50. 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 { LanguageModelRegistry, LanguageModelStatus, ReasoningSupport } from '@theia/ai-core';
18
- import { getProxyUrl } from '@theia/ai-core/lib/node';
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 { DeveloperMessageSettings, OpenAiModel, OpenAiModelUtils } from './openai-language-model';
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 }`, supports `minimal`);
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
- return responsesEffort ? { reasoning: { effort: responsesEffort } } : {};
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 has no 'minimal' — map it down to 'low'.
46
- const chatEffort =
47
- level === 'minimal' || level === 'low' ? 'low' :
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, isUsageResponsePart, LanguageModelMessage, LanguageModelStreamResponsePart, UserRequest
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 {