@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.
- package/lib/browser/google-frontend-application-contribution.d.ts +13 -10
- package/lib/browser/google-frontend-application-contribution.d.ts.map +1 -1
- package/lib/browser/google-frontend-application-contribution.js +36 -50
- package/lib/browser/google-frontend-application-contribution.js.map +1 -1
- package/lib/common/google-language-models-manager.d.ts +15 -1
- package/lib/common/google-language-models-manager.d.ts.map +1 -1
- package/lib/common/google-preferences.d.ts +2 -1
- package/lib/common/google-preferences.d.ts.map +1 -1
- package/lib/common/google-preferences.js +16 -7
- package/lib/common/google-preferences.js.map +1 -1
- package/lib/node/google-language-model.d.ts +9 -0
- package/lib/node/google-language-model.d.ts.map +1 -1
- package/lib/node/google-language-model.js +22 -8
- package/lib/node/google-language-model.js.map +1 -1
- package/lib/node/google-language-model.spec.js +24 -0
- package/lib/node/google-language-model.spec.js.map +1 -1
- package/lib/node/google-language-models-manager-impl.d.ts +43 -1
- package/lib/node/google-language-models-manager-impl.d.ts.map +1 -1
- package/lib/node/google-language-models-manager-impl.js +97 -3
- package/lib/node/google-language-models-manager-impl.js.map +1 -1
- package/lib/node/google-language-models-manager-impl.spec.js +162 -0
- package/lib/node/google-language-models-manager-impl.spec.js.map +1 -1
- package/package.json +5 -5
- package/src/browser/google-frontend-application-contribution.ts +40 -55
- package/src/common/google-language-models-manager.ts +16 -2
- package/src/common/google-preferences.ts +17 -6
- package/src/node/google-language-model.spec.ts +32 -1
- package/src/node/google-language-model.ts +31 -6
- package/src/node/google-language-models-manager-impl.spec.ts +164 -1
- 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
|
|
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
|
-
[
|
|
37
|
-
type: '
|
|
38
|
-
|
|
37
|
+
[ALLOW_ENV_API_KEY_PREF]: {
|
|
38
|
+
type: 'boolean',
|
|
39
|
+
default: false,
|
|
39
40
|
title: AI_CORE_PREFERENCES_TITLE,
|
|
40
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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 =
|
|
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;
|