@theia/ai-google 1.76.0-next.28 → 1.76.0-next.34

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 (27) hide show
  1. package/lib/browser/google-frontend-application-contribution.d.ts +13 -10
  2. package/lib/browser/google-frontend-application-contribution.d.ts.map +1 -1
  3. package/lib/browser/google-frontend-application-contribution.js +36 -50
  4. package/lib/browser/google-frontend-application-contribution.js.map +1 -1
  5. package/lib/common/google-language-models-manager.d.ts +15 -1
  6. package/lib/common/google-language-models-manager.d.ts.map +1 -1
  7. package/lib/common/google-preferences.d.ts +2 -1
  8. package/lib/common/google-preferences.d.ts.map +1 -1
  9. package/lib/common/google-preferences.js +16 -7
  10. package/lib/common/google-preferences.js.map +1 -1
  11. package/lib/node/google-language-model.d.ts +9 -0
  12. package/lib/node/google-language-model.d.ts.map +1 -1
  13. package/lib/node/google-language-model.js +8 -4
  14. package/lib/node/google-language-model.js.map +1 -1
  15. package/lib/node/google-language-models-manager-impl.d.ts +43 -1
  16. package/lib/node/google-language-models-manager-impl.d.ts.map +1 -1
  17. package/lib/node/google-language-models-manager-impl.js +97 -3
  18. package/lib/node/google-language-models-manager-impl.js.map +1 -1
  19. package/lib/node/google-language-models-manager-impl.spec.js +162 -0
  20. package/lib/node/google-language-models-manager-impl.spec.js.map +1 -1
  21. package/package.json +4 -4
  22. package/src/browser/google-frontend-application-contribution.ts +40 -55
  23. package/src/common/google-language-models-manager.ts +16 -2
  24. package/src/common/google-preferences.ts +17 -6
  25. package/src/node/google-language-model.ts +15 -2
  26. package/src/node/google-language-models-manager-impl.spec.ts +164 -1
  27. package/src/node/google-language-models-manager-impl.ts +109 -5
@@ -156,6 +156,20 @@ function toGoogleRole(message: LanguageModelMessage): 'user' | 'model' {
156
156
  * Implements the Gemini language model integration for Theia. Reasoning-level
157
157
  * translation lives in {@link googleReasoningFor}.
158
158
  */
159
+ /** Options for {@link createGoogleClient}. */
160
+ export interface GoogleClientOptions {
161
+ readonly apiKey: string;
162
+ }
163
+
164
+ /**
165
+ * The single place a Gemini SDK client is built, so that a chat request, a model lookup and the model
166
+ * discovery all reach the provider the same way.
167
+ */
168
+ export function createGoogleClient(options: GoogleClientOptions): GoogleGenAI {
169
+ // TODO test vertexai
170
+ return new GoogleGenAI({ apiKey: options.apiKey, vertexai: false });
171
+ }
172
+
159
173
  export class GoogleModel implements LanguageModel {
160
174
 
161
175
  /** Provider identifier, used to key per-provider settings (e.g. server tool selections) and the capabilities UI. */
@@ -580,8 +594,7 @@ export class GoogleModel implements LanguageModel {
580
594
  throw new Error('Please provide GOOGLE_API_KEY in preferences or via environment variable');
581
595
  }
582
596
 
583
- // TODO test vertexai
584
- return new GoogleGenAI({ apiKey, vertexai: false });
597
+ return createGoogleClient({ apiKey });
585
598
  }
586
599
 
587
600
  /**
@@ -16,10 +16,11 @@
16
16
 
17
17
  import { expect } from 'chai';
18
18
  import type { Model } from '@google/genai';
19
- import { ReasoningApi } from '@theia/ai-core';
19
+ import { DiscoveredModel, ReasoningApi } from '@theia/ai-core';
20
20
  import { GoogleLanguageModelsManagerImpl, reasoningApiFromModelId } from './google-language-models-manager-impl';
21
21
  import { GoogleModelDescription } from '../common';
22
22
  import { MockLogger } from '@theia/core/lib/common/test/mock-logger';
23
+ import { TestModelDiscoveryFetcher } from '@theia/ai-core/lib/node/test/test-model-discovery-fetcher';
23
24
 
24
25
  class TestableGoogleManager extends GoogleLanguageModelsManagerImpl {
25
26
  public retrieveCalls: string[] = [];
@@ -46,6 +47,36 @@ class TestableGoogleManager extends GoogleLanguageModelsManagerImpl {
46
47
  }
47
48
  return this.stubbedInfo;
48
49
  }
50
+
51
+ public stubbedModels: Model[] = [];
52
+ public listCalls = 0;
53
+ /** Throw {@link failWith} for the first `failTimes` list calls, then return {@link stubbedModels}. */
54
+ public failTimes = 0;
55
+ public failWith: Error = new Error('boom');
56
+ /** The real discovery fetcher, snapshotting in memory and retrying without waiting. */
57
+ public readonly testFetcher = new TestModelDiscoveryFetcher();
58
+ protected override readonly discoveryFetcher = this.testFetcher;
59
+
60
+ /** In-memory stand-in for the on-disk snapshot. */
61
+ public get snapshot(): DiscoveredModel[] | undefined {
62
+ return this.testFetcher.snapshot;
63
+ }
64
+
65
+ public set snapshot(models: DiscoveredModel[] | undefined) {
66
+ this.testFetcher.snapshot = models;
67
+ }
68
+
69
+ protected override async listModels(_apiKey: string): Promise<Model[]> {
70
+ this.listCalls++;
71
+ if (this.listCalls <= this.failTimes) {
72
+ throw this.failWith;
73
+ }
74
+ return this.stubbedModels;
75
+ }
76
+ }
77
+
78
+ function listedModel(name: string, supportedActions?: string[], displayName?: string, modelDescription?: string): Model {
79
+ return ({ name, supportedActions, displayName, description: modelDescription }) as unknown as Model;
49
80
  }
50
81
 
51
82
  function description(model: string): GoogleModelDescription {
@@ -184,3 +215,135 @@ describe('GoogleLanguageModelsManagerImpl - fetchModelInfo cache', () => {
184
215
  expect(manager.retrieveCalls).to.deep.equal(['gemini-3-pro']);
185
216
  });
186
217
  });
218
+
219
+ describe('GoogleLanguageModelsManagerImpl - fetchAvailableModels', () => {
220
+ let manager: TestableGoogleManager;
221
+
222
+ beforeEach(() => {
223
+ manager = new TestableGoogleManager();
224
+ (manager as unknown as { logger: MockLogger }).logger = new MockLogger();
225
+ manager.setApiKey('key');
226
+ });
227
+
228
+ it('returns an empty result and skips the network when no API key is set', async () => {
229
+ const previous = { google: process.env.GOOGLE_API_KEY, gemini: process.env.GEMINI_API_KEY };
230
+ delete process.env.GOOGLE_API_KEY;
231
+ delete process.env.GEMINI_API_KEY;
232
+ try {
233
+ manager.setApiKey(undefined);
234
+ expect(await manager.fetchAvailableModels()).to.deep.equal({ models: [], fromCache: false });
235
+ expect(manager.listCalls).to.equal(0);
236
+ } finally {
237
+ if (previous.google !== undefined) { process.env.GOOGLE_API_KEY = previous.google; }
238
+ if (previous.gemini !== undefined) { process.env.GEMINI_API_KEY = previous.gemini; }
239
+ }
240
+ });
241
+
242
+ it('strips the "models/" prefix, drops models that cannot generate content, and caches the result', async () => {
243
+ manager.stubbedModels = [
244
+ listedModel('models/gemini-3-pro', ['generateContent', 'countTokens'], 'Gemini 3 Pro', 'The best one.'),
245
+ listedModel('models/text-embedding-004', ['embedContent']),
246
+ // Older SDKs omit the field; such models are kept rather than silently dropped.
247
+ listedModel('models/gemini-3-flash'),
248
+ listedModel('models/gemini-3-pro', ['generateContent'])
249
+ ];
250
+ const result = await manager.fetchAvailableModels();
251
+ expect(result.fromCache).to.equal(false);
252
+ expect(result.models.map(model => model.id)).to.deep.equal(['gemini-3-pro', 'gemini-3-flash']);
253
+ expect(result.models[0].label).to.equal('Gemini 3 Pro');
254
+ expect(result.models[0].description).to.equal('The best one.');
255
+ expect(manager.snapshot).to.deep.equal(result.models);
256
+ });
257
+
258
+ it('keeps the Gemini chat models and drops what the endpoint otherwise carries', async () => {
259
+ manager.stubbedModels = [
260
+ listedModel('models/gemini-flash-latest'),
261
+ listedModel('models/gemini-3.8-flash'),
262
+ listedModel('models/gemini-3.1-pro-preview'),
263
+ // Chat-capable, but not a model of this family: its parameter count would read as a version.
264
+ listedModel('models/gemma-3-27b-it'),
265
+ listedModel('models/learnlm-2.0-experimental'),
266
+ // Gemini, and reported as generating content, but not chat: they answer through an API of
267
+ // their own, or they generate something other than text.
268
+ listedModel('models/gemini-3.5-pro-deep-research'),
269
+ listedModel('models/gemini-2.5-flash-preview-tts'),
270
+ listedModel('models/gemini-2.5-flash-image'),
271
+ listedModel('models/gemini-2.5-computer-use-preview'),
272
+ // The Live API and embedding models need no id term: they report what they can do.
273
+ listedModel('models/gemini-2.5-flash-live', ['bidiGenerateContent']),
274
+ listedModel('models/gemini-embedding-001', ['embedContent']),
275
+ listedModel('models/imagen-4.0-generate-001')
276
+ ];
277
+ const result = await manager.fetchAvailableModels();
278
+ expect(result.models.map(model => model.id)).to.deep.equal([
279
+ 'gemini-flash-latest',
280
+ 'gemini-3.8-flash',
281
+ 'gemini-3.1-pro-preview'
282
+ ]);
283
+ });
284
+
285
+ it('retries transient network errors and then succeeds', async () => {
286
+ manager.stubbedModels = [listedModel('models/gemini-3-pro')];
287
+ manager.failTimes = 2;
288
+ manager.failWith = new Error('fetch failed');
289
+ const result = await manager.fetchAvailableModels();
290
+ expect(result.models.map(model => model.id)).to.deep.equal(['gemini-3-pro']);
291
+ expect(manager.listCalls).to.equal(3);
292
+ });
293
+
294
+ it('falls back to the cached snapshot when the fetch keeps failing', async () => {
295
+ manager.snapshot = [{ id: 'gemini-3-pro' }];
296
+ manager.failTimes = 99;
297
+ manager.failWith = new Error('ETIMEDOUT');
298
+ const result = await manager.fetchAvailableModels();
299
+ expect(result).to.deep.equal({ models: [{ id: 'gemini-3-pro' }], fromCache: true, error: 'ETIMEDOUT' });
300
+ expect(manager.listCalls).to.equal(3);
301
+ });
302
+
303
+ it('does not retry auth errors and throws without a snapshot', async () => {
304
+ manager.failTimes = 99;
305
+ manager.failWith = new Error('403 permission denied');
306
+ let threw = false;
307
+ try {
308
+ await manager.fetchAvailableModels();
309
+ } catch (error) {
310
+ threw = true;
311
+ expect((error as Error).message).to.equal('403 permission denied');
312
+ }
313
+ expect(threw).to.be.true;
314
+ expect(manager.listCalls).to.equal(1);
315
+ });
316
+ });
317
+
318
+ describe('GoogleLanguageModelsManagerImpl - environment API key consent', () => {
319
+
320
+ it('leaves an environment key unused until it is allowed, wherever a key would be read', async () => {
321
+ const previous = { GOOGLE_API_KEY: process.env.GOOGLE_API_KEY, GEMINI_API_KEY: process.env.GEMINI_API_KEY };
322
+ delete process.env.GOOGLE_API_KEY;
323
+ delete process.env.GEMINI_API_KEY;
324
+ process.env.GOOGLE_API_KEY = 'from-the-environment';
325
+ try {
326
+ const manager = new TestableGoogleManager();
327
+ // The gate sits on the key, so a custom endpoint or a configured model cannot reach past it either.
328
+ expect(manager.apiKey).to.equal(undefined);
329
+ // Discovery still needs to know the key is there, in order to ask for it.
330
+ expect(await manager.getApiKeySource()).to.equal('environment');
331
+
332
+ manager.setAllowEnvironmentApiKey(true);
333
+ expect(manager.apiKey).to.equal('from-the-environment');
334
+
335
+ // Withdrawing the consent stops it being used at once.
336
+ manager.setAllowEnvironmentApiKey(false);
337
+ expect(manager.apiKey).to.equal(undefined);
338
+ } finally {
339
+ if (previous.GOOGLE_API_KEY !== undefined) { process.env.GOOGLE_API_KEY = previous.GOOGLE_API_KEY; } else { delete process.env.GOOGLE_API_KEY; }
340
+ if (previous.GEMINI_API_KEY !== undefined) { process.env.GEMINI_API_KEY = previous.GEMINI_API_KEY; } else { delete process.env.GEMINI_API_KEY; }
341
+ }
342
+ });
343
+
344
+ it('uses a key set in the preferences whatever the environment says', () => {
345
+ const manager = new TestableGoogleManager();
346
+ manager.setApiKey('from-the-preference');
347
+ expect(manager.apiKey).to.equal('from-the-preference');
348
+ });
349
+ });
@@ -14,14 +14,19 @@
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, ReasoningApi, ReasoningSupport } from '@theia/ai-core';
17
+ import {
18
+ ApiKeySource, DiscoveredModel, DiscoveredModels, LanguageModelRegistry, LanguageModelStatus, ModelDiscoveryResult, ReasoningApi, ReasoningSupport
19
+ } from '@theia/ai-core';
20
+ import { ModelDiscoveryFetcher } from '@theia/ai-core/lib/node';
18
21
  import { inject, injectable, named } from '@theia/core/shared/inversify';
19
- import { GoogleGenAI, Model } from '@google/genai';
20
- import { GoogleModel } from './google-language-model';
22
+ import { Model } from '@google/genai';
23
+ import { createGoogleClient, GoogleModel } from './google-language-model';
21
24
  import { GOOGLE_SERVER_TOOLS } from './google-server-tools';
22
25
  import { GoogleLanguageModelsManager, GoogleModelDescription } from '../common';
23
26
  import { ILogger } from '@theia/core';
24
27
 
28
+ const GOOGLE_SNAPSHOT_FILE = 'google-models.json';
29
+
25
30
  export interface GoogleLanguageModelRetrySettings {
26
31
  maxRetriesOnErrors: number;
27
32
  retryDelayOnRateLimitError: number;
@@ -56,6 +61,12 @@ interface ResolvedModelMetadata {
56
61
  @injectable()
57
62
  export class GoogleLanguageModelsManagerImpl implements GoogleLanguageModelsManager {
58
63
  protected _apiKey: string | undefined;
64
+ /**
65
+ * Whether a key found in the environment may be used. Withheld until the user confirms it, so the
66
+ * gate sits on the key itself: every path that reaches for one — discovery, a custom endpoint, a
67
+ * manually configured model — is covered, and revoking the consent takes effect at once.
68
+ */
69
+ protected _allowEnvironmentApiKey = false;
59
70
  protected retrySettings: GoogleLanguageModelRetrySettings = {
60
71
  maxRetriesOnErrors: 3,
61
72
  retryDelayOnRateLimitError: 60,
@@ -71,8 +82,97 @@ export class GoogleLanguageModelsManagerImpl implements GoogleLanguageModelsMana
71
82
  @inject(ILogger) @named('ai-google:GoogleLanguageModelsManagerImpl')
72
83
  protected readonly logger: ILogger;
73
84
 
85
+ @inject(ModelDiscoveryFetcher)
86
+ protected readonly discoveryFetcher: ModelDiscoveryFetcher;
87
+
74
88
  get apiKey(): string | undefined {
75
- return this._apiKey ?? process.env.GOOGLE_API_KEY ?? process.env.GEMINI_API_KEY;
89
+ return this._apiKey ?? (this._allowEnvironmentApiKey ? process.env.GOOGLE_API_KEY ?? process.env.GEMINI_API_KEY : undefined);
90
+ }
91
+
92
+ async getApiKeySource(): Promise<ApiKeySource> {
93
+ if (this._apiKey) {
94
+ return 'preference';
95
+ }
96
+ if (process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY) {
97
+ return 'environment';
98
+ }
99
+ return 'none';
100
+ }
101
+
102
+ async fetchAvailableModels(): Promise<ModelDiscoveryResult> {
103
+ const apiKey = this.apiKey;
104
+ if (!apiKey) {
105
+ return { models: [], fromCache: false };
106
+ }
107
+ return this.discoveryFetcher.fetch({
108
+ snapshotFile: GOOGLE_SNAPSHOT_FILE,
109
+ providerLabel: 'Google',
110
+ listModels: async () => this.toDiscoveredModels(await this.listModels(apiKey)),
111
+ isRetryable: error => this.isRetryableError(error)
112
+ });
113
+ }
114
+
115
+ /**
116
+ * Maps the endpoint's entries onto {@link DiscoveredModel}s. Gemini ids carry no release date
117
+ * today, so collapsing dated variants is a no-op here; it is applied all the same, so a provider
118
+ * that starts pinning releases does not quietly multiply the list.
119
+ */
120
+ protected toDiscoveredModels(models: Model[]): DiscoveredModel[] {
121
+ const byId = new Map<string, DiscoveredModel>();
122
+ for (const model of models) {
123
+ // Only models usable for chat/content generation; older SDKs may omit the field.
124
+ if (model.supportedActions && !model.supportedActions.includes('generateContent')) {
125
+ continue;
126
+ }
127
+ const id = (model.name ?? '').replace(/^models\//, '');
128
+ if (id.length > 0 && this.isChatModelId(id) && !byId.has(id)) {
129
+ byId.set(id, { id, label: model.displayName, description: model.description });
130
+ }
131
+ }
132
+ return DiscoveredModels.withUndatedAliases([...byId.values()]);
133
+ }
134
+
135
+ /**
136
+ * Heuristic for the Gemini chat models among the entries that are left once {@link toDiscoveredModels}
137
+ * has dropped what cannot generate content at all. Two things remain to be excluded, and both
138
+ * report `generateContent` like a chat model: a model that generates something other than text
139
+ * (an image, speech), and one that answers through an agentic API of its own (deep research,
140
+ * computer use, robotics). Everything outside the `gemini-*` family goes too — Gemma, LearnLM —
141
+ * since being able to generate content does not make a model one of the models this provider is
142
+ * about. Anything this misses can be configured as a custom endpoint.
143
+ *
144
+ * The Live API models need no term of their own: they report `bidiGenerateContent`, so the
145
+ * capability check has already left them out.
146
+ *
147
+ * Without a release date to go by, the ranking that decides which models the chat input offers
148
+ * falls back to the numbers in the id, and those only mean a version within this family: a
149
+ * parameter count (`gemma-3-27b-it`) or an experiment date would otherwise read as the newest
150
+ * model there is.
151
+ */
152
+ protected isChatModelId(id: string): boolean {
153
+ if (!/^gemini-/.test(id)) {
154
+ return false;
155
+ }
156
+ return !/(image|tts|deep-research|computer-use|robotics)/.test(id);
157
+ }
158
+
159
+ /**
160
+ * Retry transient network errors; auth/quota errors fail fast. The SDK reports them as plain
161
+ * errors, so there is nothing to match on but the message.
162
+ */
163
+ protected isRetryableError(error: unknown): boolean {
164
+ const message = (error instanceof Error ? `${error.name} ${error.message}` : String(error)).toLowerCase();
165
+ return /econn|etimedout|enotfound|network|fetch failed|socket|timeout|aborted/.test(message);
166
+ }
167
+
168
+ /** Iterates the (auto-paginated) `/v1beta/models` endpoint. Overridable for testing. */
169
+ protected async listModels(apiKey: string): Promise<Model[]> {
170
+ const genAI = createGoogleClient({ apiKey });
171
+ const models: Model[] = [];
172
+ for await (const model of await genAI.models.list()) {
173
+ models.push(model);
174
+ }
175
+ return models;
76
176
  }
77
177
 
78
178
  protected calculateStatus(effectiveApiKey: string | undefined): LanguageModelStatus {
@@ -168,7 +268,7 @@ export class GoogleLanguageModelsManagerImpl implements GoogleLanguageModelsMana
168
268
  }
169
269
 
170
270
  protected retrieveModelInfo(modelDescription: GoogleModelDescription, apiKey: string): Promise<Model> {
171
- const genAI = new GoogleGenAI({ apiKey, vertexai: false });
271
+ const genAI = createGoogleClient({ apiKey });
172
272
  return genAI.models.get({ model: modelDescription.model });
173
273
  }
174
274
 
@@ -184,6 +284,10 @@ export class GoogleLanguageModelsManagerImpl implements GoogleLanguageModelsMana
184
284
  this.languageModelRegistry.removeLanguageModels(modelIds);
185
285
  }
186
286
 
287
+ setAllowEnvironmentApiKey(allowed: boolean): void {
288
+ this._allowEnvironmentApiKey = allowed;
289
+ }
290
+
187
291
  setApiKey(apiKey: string | undefined): void {
188
292
  if (apiKey) {
189
293
  this._apiKey = apiKey;