@theia/ai-google 1.76.0-next.6 → 1.76.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) 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 +22 -8
  14. package/lib/node/google-language-model.js.map +1 -1
  15. package/lib/node/google-language-model.spec.js +24 -0
  16. package/lib/node/google-language-model.spec.js.map +1 -1
  17. package/lib/node/google-language-models-manager-impl.d.ts +43 -1
  18. package/lib/node/google-language-models-manager-impl.d.ts.map +1 -1
  19. package/lib/node/google-language-models-manager-impl.js +97 -3
  20. package/lib/node/google-language-models-manager-impl.js.map +1 -1
  21. package/lib/node/google-language-models-manager-impl.spec.js +162 -0
  22. package/lib/node/google-language-models-manager-impl.spec.js.map +1 -1
  23. package/package.json +5 -5
  24. package/src/browser/google-frontend-application-contribution.ts +40 -55
  25. package/src/common/google-language-models-manager.ts +16 -2
  26. package/src/common/google-preferences.ts +17 -6
  27. package/src/node/google-language-model.spec.ts +32 -1
  28. package/src/node/google-language-model.ts +31 -6
  29. package/src/node/google-language-models-manager-impl.spec.ts +164 -1
  30. package/src/node/google-language-models-manager-impl.ts +109 -5
@@ -18,7 +18,8 @@ import { AI_CORE_PREFERENCES_TITLE, MODEL_PROVIDER_TYPE_DETAIL, ModelProviderTyp
18
18
  import { LINUX_ENV_HINT, nls, PreferenceSchema } from '@theia/core';
19
19
 
20
20
  export const API_KEY_PREF = 'ai-features.google.apiKey';
21
- export const MODELS_PREF = 'ai-features.google.models';
21
+ export const ALLOW_ENV_API_KEY_PREF = 'ai-features.google.allowEnvironmentApiKey';
22
+ export const MODEL_OVERRIDES_PREF = 'ai-features.google.modelOverrides';
22
23
  export const MAX_RETRIES = 'ai-features.google.maxRetriesOnErrors';
23
24
  export const RETRY_DELAY_RATE_LIMIT = 'ai-features.google.retryDelayOnRateLimitError';
24
25
  export const RETRY_DELAY_OTHER_ERRORS = 'ai-features.google.retryDelayOnOtherErrors';
@@ -33,14 +34,24 @@ export const GooglePreferencesSchema: PreferenceSchema = {
33
34
  on the machine running Theia. Use the environment variable `GOOGLE_API_KEY` to set the key securely.') + LINUX_ENV_HINT,
34
35
  title: AI_CORE_PREFERENCES_TITLE,
35
36
  },
36
- [MODELS_PREF]: {
37
- type: 'array',
38
- description: nls.localize('theia/ai/google/models/description', 'Official Google Gemini models to use'),
37
+ [ALLOW_ENV_API_KEY_PREF]: {
38
+ type: 'boolean',
39
+ default: false,
39
40
  title: AI_CORE_PREFERENCES_TITLE,
40
- default: ['gemini-3.1-pro-preview', 'gemini-3.7-flash', 'gemini-3.6-flash', 'gemini-3.5-flash', 'gemini-3.5-flash-lite'],
41
+ markdownDescription: nls.localize('theia/ai/google/allowEnvApiKey/description',
42
+ 'Allow Theia to use a Google AI (Gemini) API key found in the environment (`GOOGLE_API_KEY` / `GEMINI_API_KEY`). '
43
+ + 'You are asked to confirm this once before the key is used; set it back to `false` to revoke consent.'),
44
+ },
45
+ [MODEL_OVERRIDES_PREF]: {
46
+ type: 'array',
47
+ default: [],
41
48
  items: {
42
49
  type: 'string'
43
- }
50
+ },
51
+ title: AI_CORE_PREFERENCES_TITLE,
52
+ markdownDescription: nls.localize('theia/ai/google/modelOverrides/description',
53
+ 'Override the models discovered from Google Gemini. When empty (default), the available models are discovered from the provider. '
54
+ + 'Set explicit model ids to use exactly those instead; discovery is then not used at all.')
44
55
  },
45
56
  [MAX_RETRIES]: {
46
57
  type: 'integer',
@@ -15,7 +15,8 @@
15
15
  // *****************************************************************************
16
16
 
17
17
  import { expect } from 'chai';
18
- import { LanguageModelRequest, ReasoningApi, ReasoningSupport } from '@theia/ai-core';
18
+ import { LanguageModelRequest, LanguageModelTextResponse, ReasoningApi, ReasoningSupport } from '@theia/ai-core';
19
+ import type { GoogleGenAI } from '@google/genai';
19
20
  import { GoogleModel } from './google-language-model';
20
21
 
21
22
  const GEMINI_REASONING_SUPPORT: ReasoningSupport = {
@@ -92,3 +93,33 @@ describe('GoogleModel reasoning translation', () => {
92
93
  });
93
94
  });
94
95
  });
96
+
97
+ describe('GoogleModel non-streaming requests', () => {
98
+ /** Model whose generateContent() resolves to the given response instead of calling the API. */
99
+ class NonStreamingGoogleModel extends GoogleModel {
100
+ constructor(protected readonly generateContentResponse: object) {
101
+ super(
102
+ 'test-id', 'gemini-3-pro', { status: 'ready' }, false,
103
+ () => 'test-key',
104
+ () => ({ maxRetriesOnErrors: 0, retryDelayOnRateLimitError: -1, retryDelayOnOtherErrors: -1 }),
105
+ GEMINI_REASONING_SUPPORT, 'effort'
106
+ );
107
+ }
108
+ protected override initializeGemini(): GoogleGenAI {
109
+ return { models: { generateContent: async () => this.generateContentResponse } } as unknown as GoogleGenAI;
110
+ }
111
+ }
112
+
113
+ it('excludes thought parts from the response text', async () => {
114
+ const model = new NonStreamingGoogleModel({
115
+ candidates: [{ content: { role: 'model', parts: [{ text: 'Weighing the options.', thought: true }, { text: 'Answer' }] } }],
116
+ usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5 }
117
+ });
118
+ const response = await model.request({
119
+ messages: [{ actor: 'user', type: 'text', text: 'hello' }],
120
+ reasoning: { level: 'medium' },
121
+ agentId: 'test', sessionId: 'session', requestId: 'req'
122
+ });
123
+ expect((response as LanguageModelTextResponse).text).to.equal('Answer');
124
+ });
125
+ });
@@ -30,6 +30,7 @@ import {
30
30
  ReasoningSupport,
31
31
  ServerToolCall,
32
32
  ServerToolDescriptor,
33
+ TokenUsageParams,
33
34
  ToolCallResult,
34
35
  ToolInvocationContext,
35
36
  UserRequest
@@ -155,6 +156,20 @@ function toGoogleRole(message: LanguageModelMessage): 'user' | 'model' {
155
156
  * Implements the Gemini language model integration for Theia. Reasoning-level
156
157
  * translation lives in {@link googleReasoningFor}.
157
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
+
158
173
  export class GoogleModel implements LanguageModel {
159
174
 
160
175
  /** Provider identifier, used to key per-provider settings (e.g. server tool selections) and the capabilities UI. */
@@ -246,6 +261,7 @@ export class GoogleModel implements LanguageModel {
246
261
  let latestUrlContextMetadata: UrlContextMetadata | undefined;
247
262
  let latestGroundingMetadata: GroundingMetadata | undefined;
248
263
  try {
264
+ let tokenUsage: TokenUsageParams | undefined = undefined;
249
265
  for await (const chunk of stream) {
250
266
  if (cancellationToken?.isCancellationRequested) {
251
267
  break;
@@ -345,16 +361,25 @@ export class GoogleModel implements LanguageModel {
345
361
  yield { content: chunk.text };
346
362
  }
347
363
 
348
- // Report token usage if available
364
+ // Remember the token usage as Gemini's metadata is cumulative
349
365
  if (chunk.usageMetadata) {
350
366
  const promptTokens = chunk.usageMetadata.promptTokenCount;
351
367
  const completionTokens = chunk.usageMetadata.candidatesTokenCount;
352
368
  if (promptTokens !== undefined && completionTokens !== undefined) {
353
- yield { input_tokens: promptTokens, output_tokens: completionTokens };
369
+ tokenUsage = {
370
+ inputTokens: promptTokens,
371
+ outputTokens: completionTokens,
372
+ requestId: request.requestId
373
+ };
354
374
  }
355
375
  }
356
376
  }
357
377
 
378
+ // Report token usage if available
379
+ if (tokenUsage !== undefined && that.id) {
380
+ yield { input_tokens: tokenUsage.inputTokens, output_tokens: tokenUsage.outputTokens };
381
+ }
382
+
358
383
  // Surface any server tools (url_context / google_search) that the provider executed.
359
384
  const serverToolCalls = that.buildServerToolCalls(latestUrlContextMetadata, latestGroundingMetadata);
360
385
  if (serverToolCalls.length > 0) {
@@ -538,10 +563,11 @@ export class GoogleModel implements LanguageModel {
538
563
 
539
564
  try {
540
565
  let responseText = '';
541
- // For non streaming requests we are always only interested in text parts
566
+ // For non streaming requests we are always only interested in text parts; thought summaries
567
+ // (parts flagged `thought`, present when includeThoughts is set) are not part of the answer.
542
568
  if (model.candidates?.[0]?.content?.parts) {
543
569
  for (const part of model.candidates[0].content.parts) {
544
- if (part.text) {
570
+ if (part.text && !part.thought) {
545
571
  responseText += part.text;
546
572
  }
547
573
  }
@@ -569,8 +595,7 @@ export class GoogleModel implements LanguageModel {
569
595
  throw new Error('Please provide GOOGLE_API_KEY in preferences or via environment variable');
570
596
  }
571
597
 
572
- // TODO test vertexai
573
- return new GoogleGenAI({ apiKey, vertexai: false });
598
+ return createGoogleClient({ apiKey });
574
599
  }
575
600
 
576
601
  /**
@@ -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;