geminisdk 0.1.1

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/src/backend.ts ADDED
@@ -0,0 +1,615 @@
1
+ /**
2
+ * Backend for Gemini CLI / Google Code Assist API.
3
+ */
4
+
5
+ import { v4 as uuidv4 } from 'uuid';
6
+ import { GeminiOAuthManager } from './auth.js';
7
+ import {
8
+ APIError,
9
+ OnboardingError,
10
+ PermissionDeniedError,
11
+ RateLimitError,
12
+ } from './exceptions.js';
13
+ import {
14
+ FunctionCall,
15
+ GenerationConfig,
16
+ HTTP_FORBIDDEN,
17
+ HTTP_UNAUTHORIZED,
18
+ LLMChunk,
19
+ LLMUsage,
20
+ Message,
21
+ Role,
22
+ ThinkingConfig,
23
+ Tool,
24
+ ToolCall,
25
+ } from './types.js';
26
+
27
+ const RETRYABLE_STATUS_CODES = new Set([HTTP_UNAUTHORIZED, HTTP_FORBIDDEN]);
28
+ const ONBOARD_MAX_RETRIES = 30;
29
+ const ONBOARD_SLEEP_SECONDS = 2;
30
+
31
+ function sleep(ms: number): Promise<void> {
32
+ return new Promise((resolve) => setTimeout(resolve, ms));
33
+ }
34
+
35
+ // Simple UUID generator if uuid package isn't available
36
+ function generateUUID(): string {
37
+ try {
38
+ return uuidv4();
39
+ } catch {
40
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
41
+ const r = (Math.random() * 16) | 0;
42
+ const v = c === 'x' ? r : (r & 0x3) | 0x8;
43
+ return v.toString(16);
44
+ });
45
+ }
46
+ }
47
+
48
+ export interface BackendOptions {
49
+ timeout?: number;
50
+ oauthPath?: string;
51
+ clientId?: string;
52
+ clientSecret?: string;
53
+ }
54
+
55
+ export class GeminiBackend {
56
+ private timeout: number;
57
+ private oauthManager: GeminiOAuthManager;
58
+ private projectId: string | null = null;
59
+
60
+ constructor(options: BackendOptions = {}) {
61
+ this.timeout = options.timeout ?? 720000;
62
+ this.oauthManager = new GeminiOAuthManager(
63
+ options.oauthPath,
64
+ options.clientId,
65
+ options.clientSecret
66
+ );
67
+ }
68
+
69
+ private async getAuthHeaders(forceRefresh = false): Promise<Record<string, string>> {
70
+ const accessToken = await this.oauthManager.ensureAuthenticated(forceRefresh);
71
+ return {
72
+ 'Content-Type': 'application/json',
73
+ Authorization: `Bearer ${accessToken}`,
74
+ };
75
+ }
76
+
77
+ private prepareMessages(messages: Message[]): Array<Record<string, unknown>> {
78
+ const result: Array<Record<string, unknown>> = [];
79
+
80
+ for (const msg of messages) {
81
+ const role = msg.role === Role.ASSISTANT ? 'model' : 'user';
82
+ const contentParts: Array<Record<string, unknown>> = [];
83
+
84
+ if (msg.content) {
85
+ if (typeof msg.content === 'string') {
86
+ contentParts.push({ text: msg.content });
87
+ } else {
88
+ for (const part of msg.content) {
89
+ if (part.text) {
90
+ contentParts.push({ text: part.text });
91
+ } else if (part.imageData && part.imageMimeType) {
92
+ contentParts.push({
93
+ inlineData: {
94
+ mimeType: part.imageMimeType,
95
+ data:
96
+ part.imageData instanceof Uint8Array
97
+ ? Buffer.from(part.imageData).toString('base64')
98
+ : part.imageData,
99
+ },
100
+ });
101
+ }
102
+ }
103
+ }
104
+ }
105
+
106
+ if (msg.toolCalls) {
107
+ for (const tc of msg.toolCalls) {
108
+ const args =
109
+ typeof tc.function.arguments === 'string'
110
+ ? JSON.parse(tc.function.arguments)
111
+ : tc.function.arguments;
112
+ contentParts.push({
113
+ functionCall: {
114
+ name: tc.function.name,
115
+ args,
116
+ },
117
+ });
118
+ }
119
+ }
120
+
121
+ if (msg.toolCallId) {
122
+ contentParts.push({
123
+ functionResponse: {
124
+ name: msg.name ?? '',
125
+ response:
126
+ typeof msg.content === 'string'
127
+ ? { result: msg.content }
128
+ : msg.content,
129
+ },
130
+ });
131
+ }
132
+
133
+ if (contentParts.length > 0) {
134
+ result.push({ role, parts: contentParts });
135
+ }
136
+ }
137
+
138
+ return result;
139
+ }
140
+
141
+ private prepareTools(tools?: Tool[]): Array<Record<string, unknown>> | undefined {
142
+ if (!tools || tools.length === 0) return undefined;
143
+
144
+ const funcDecls: Array<Record<string, unknown>> = [];
145
+
146
+ for (const tool of tools) {
147
+ const funcDef: Record<string, unknown> = {
148
+ name: tool.name,
149
+ description: tool.description ?? '',
150
+ };
151
+
152
+ if (tool.parameters) {
153
+ funcDef['parameters'] = {
154
+ type: 'object',
155
+ properties: (tool.parameters as Record<string, unknown>)['properties'] ?? {},
156
+ required: (tool.parameters as Record<string, unknown>)['required'] ?? [],
157
+ };
158
+ }
159
+
160
+ funcDecls.push(funcDef);
161
+ }
162
+
163
+ return [{ functionDeclarations: funcDecls }];
164
+ }
165
+
166
+ private async ensureProjectId(accessToken: string): Promise<string> {
167
+ if (this.projectId !== null) return this.projectId;
168
+
169
+ const envProjectId = this.oauthManager.getProjectId();
170
+ const headers = {
171
+ Authorization: `Bearer ${accessToken}`,
172
+ 'Content-Type': 'application/json',
173
+ };
174
+
175
+ const clientMetadata = {
176
+ ideType: 'IDE_UNSPECIFIED',
177
+ platform: 'PLATFORM_UNSPECIFIED',
178
+ pluginType: 'GEMINI',
179
+ duetProject: envProjectId,
180
+ };
181
+
182
+ const loadRequest = {
183
+ cloudaicompanionProject: envProjectId,
184
+ metadata: clientMetadata,
185
+ };
186
+
187
+ try {
188
+ const url = `${this.oauthManager.getApiEndpoint()}:loadCodeAssist`;
189
+ const response = await fetch(url, {
190
+ method: 'POST',
191
+ headers,
192
+ body: JSON.stringify(loadRequest),
193
+ });
194
+
195
+ if (!response.ok) {
196
+ throw new Error(`HTTP ${response.status}`);
197
+ }
198
+
199
+ const data = (await response.json()) as Record<string, unknown>;
200
+
201
+ if (data['currentTier']) {
202
+ const projectFromApi = data['cloudaicompanionProject'] as string | undefined;
203
+ if (projectFromApi) {
204
+ this.projectId = projectFromApi;
205
+ return projectFromApi;
206
+ }
207
+ if (envProjectId) {
208
+ this.projectId = envProjectId;
209
+ return envProjectId;
210
+ }
211
+ this.projectId = '';
212
+ return '';
213
+ }
214
+
215
+ // Need to onboard
216
+ const allowedTiers = (data['allowedTiers'] ?? []) as Array<Record<string, unknown>>;
217
+ let tierId = 'free-tier';
218
+ for (const tier of allowedTiers) {
219
+ if (tier['isDefault']) {
220
+ tierId = (tier['id'] as string) ?? 'free-tier';
221
+ break;
222
+ }
223
+ }
224
+
225
+ return this.onboardForProject(headers, envProjectId, clientMetadata, tierId);
226
+ } catch (error) {
227
+ throw new APIError(
228
+ `Gemini Code Assist access denied: ${error}`,
229
+ 403,
230
+ String(error)
231
+ );
232
+ }
233
+ }
234
+
235
+ private async onboardForProject(
236
+ headers: Record<string, string>,
237
+ envProjectId: string | null,
238
+ clientMetadata: Record<string, unknown>,
239
+ tierId: string
240
+ ): Promise<string> {
241
+ const onboardRequest =
242
+ tierId === 'free-tier'
243
+ ? {
244
+ tierId,
245
+ cloudaicompanionProject: null,
246
+ metadata: clientMetadata,
247
+ }
248
+ : {
249
+ tierId,
250
+ cloudaicompanionProject: envProjectId,
251
+ metadata: { ...clientMetadata, duetProject: envProjectId },
252
+ };
253
+
254
+ const url = `${this.oauthManager.getApiEndpoint()}:onboardUser`;
255
+
256
+ for (let i = 0; i < ONBOARD_MAX_RETRIES; i++) {
257
+ const response = await fetch(url, {
258
+ method: 'POST',
259
+ headers,
260
+ body: JSON.stringify(onboardRequest),
261
+ });
262
+
263
+ if (!response.ok) {
264
+ throw new Error(`Onboard failed: ${response.status}`);
265
+ }
266
+
267
+ const lroData = (await response.json()) as Record<string, unknown>;
268
+
269
+ if (lroData['done']) {
270
+ const responseData = (lroData['response'] ?? {}) as Record<string, unknown>;
271
+ const cloudAiCompanion = responseData['cloudaicompanionProject'] as
272
+ | Record<string, unknown>
273
+ | undefined;
274
+ if (cloudAiCompanion?.['id']) {
275
+ this.projectId = cloudAiCompanion['id'] as string;
276
+ return this.projectId;
277
+ }
278
+ break;
279
+ }
280
+
281
+ await sleep(ONBOARD_SLEEP_SECONDS * 1000);
282
+ }
283
+
284
+ if (tierId === 'free-tier') {
285
+ this.projectId = '';
286
+ return '';
287
+ }
288
+
289
+ throw new OnboardingError(undefined, tierId);
290
+ }
291
+
292
+ private buildRequestPayload(
293
+ model: string,
294
+ messages: Message[],
295
+ generationConfig?: GenerationConfig,
296
+ thinkingConfig?: ThinkingConfig,
297
+ tools?: Tool[],
298
+ projectId = ''
299
+ ): Record<string, unknown> {
300
+ const genConfig: Record<string, unknown> = {
301
+ temperature: generationConfig?.temperature ?? 0.7,
302
+ };
303
+
304
+ if (generationConfig?.maxOutputTokens) {
305
+ genConfig['maxOutputTokens'] = generationConfig.maxOutputTokens;
306
+ }
307
+ if (generationConfig?.topP !== undefined) {
308
+ genConfig['topP'] = generationConfig.topP;
309
+ }
310
+ if (generationConfig?.topK !== undefined) {
311
+ genConfig['topK'] = generationConfig.topK;
312
+ }
313
+ if (generationConfig?.stopSequences) {
314
+ genConfig['stopSequences'] = generationConfig.stopSequences;
315
+ }
316
+
317
+ if (thinkingConfig?.includeThoughts) {
318
+ genConfig['thinkingConfig'] = {
319
+ includeThoughts: thinkingConfig.includeThoughts,
320
+ ...(thinkingConfig.thinkingBudget && {
321
+ thinkingBudget: thinkingConfig.thinkingBudget,
322
+ }),
323
+ };
324
+ }
325
+
326
+ const requestBody: Record<string, unknown> = {
327
+ contents: this.prepareMessages(messages),
328
+ generationConfig: genConfig,
329
+ };
330
+
331
+ const preparedTools = this.prepareTools(tools);
332
+ if (preparedTools) {
333
+ requestBody['tools'] = preparedTools;
334
+ }
335
+
336
+ const payload: Record<string, unknown> = {
337
+ model,
338
+ request: requestBody,
339
+ };
340
+
341
+ if (projectId) {
342
+ payload['project'] = projectId;
343
+ }
344
+
345
+ return payload;
346
+ }
347
+
348
+ private parseCompletionResponse(data: Record<string, unknown>): LLMChunk {
349
+ const responseData = (data['response'] ?? data) as Record<string, unknown>;
350
+ const candidates = (responseData['candidates'] ?? []) as Array<Record<string, unknown>>;
351
+
352
+ if (candidates.length === 0) {
353
+ return {
354
+ content: '',
355
+ reasoningContent: undefined,
356
+ toolCalls: undefined,
357
+ usage: undefined,
358
+ finishReason: undefined,
359
+ };
360
+ }
361
+
362
+ const candidate = candidates[0]!;
363
+ const contentObj = (candidate['content'] ?? {}) as Record<string, unknown>;
364
+ const parts = (contentObj['parts'] ?? []) as Array<Record<string, unknown>>;
365
+
366
+ let textContent = '';
367
+ let reasoningContent: string | undefined;
368
+ let toolCalls: ToolCall[] | undefined;
369
+
370
+ for (const part of parts) {
371
+ if (part['text']) {
372
+ textContent += part['text'] as string;
373
+ }
374
+ if (part['thought']) {
375
+ reasoningContent = part['thought'] as string;
376
+ }
377
+ if (part['functionCall']) {
378
+ const fc = part['functionCall'] as Record<string, unknown>;
379
+ if (!toolCalls) toolCalls = [];
380
+ toolCalls.push({
381
+ id: generateUUID(),
382
+ type: 'function',
383
+ function: {
384
+ name: (fc['name'] as string) ?? '',
385
+ arguments: (fc['args'] ?? fc['arguments'] ?? {}) as Record<string, unknown>,
386
+ },
387
+ });
388
+ }
389
+ }
390
+
391
+ const usageData = (data['usageMetadata'] ??
392
+ responseData['usageMetadata'] ?? {}) as Record<string, unknown>;
393
+ let usage: LLMUsage | undefined;
394
+
395
+ if (Object.keys(usageData).length > 0) {
396
+ usage = {
397
+ promptTokens: (usageData['promptTokenCount'] as number) ?? 0,
398
+ completionTokens: (usageData['candidatesTokenCount'] as number) ?? 0,
399
+ totalTokens: (usageData['totalTokenCount'] as number) ?? 0,
400
+ };
401
+ }
402
+
403
+ return {
404
+ content: textContent,
405
+ reasoningContent,
406
+ toolCalls,
407
+ usage,
408
+ finishReason: candidate['finishReason'] as string | undefined,
409
+ };
410
+ }
411
+
412
+ public async complete(options: {
413
+ model: string;
414
+ messages: Message[];
415
+ generationConfig?: GenerationConfig;
416
+ thinkingConfig?: ThinkingConfig;
417
+ tools?: Tool[];
418
+ extraHeaders?: Record<string, string>;
419
+ }): Promise<LLMChunk> {
420
+ return this.completeWithRetry(options, 0);
421
+ }
422
+
423
+ private async completeWithRetry(
424
+ options: {
425
+ model: string;
426
+ messages: Message[];
427
+ generationConfig?: GenerationConfig;
428
+ thinkingConfig?: ThinkingConfig;
429
+ tools?: Tool[];
430
+ extraHeaders?: Record<string, string>;
431
+ },
432
+ retryCount: number
433
+ ): Promise<LLMChunk> {
434
+ const headers = await this.getAuthHeaders(retryCount > 0);
435
+ if (options.extraHeaders) {
436
+ Object.assign(headers, options.extraHeaders);
437
+ }
438
+
439
+ const accessToken = headers['Authorization']!.replace('Bearer ', '');
440
+ const projectId = await this.ensureProjectId(accessToken);
441
+ const url = `${this.oauthManager.getApiEndpoint()}:generateContent`;
442
+
443
+ const payload = this.buildRequestPayload(
444
+ options.model,
445
+ options.messages,
446
+ options.generationConfig,
447
+ options.thinkingConfig,
448
+ options.tools,
449
+ projectId
450
+ );
451
+
452
+ const controller = new AbortController();
453
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
454
+
455
+ try {
456
+ const response = await fetch(url, {
457
+ method: 'POST',
458
+ headers,
459
+ body: JSON.stringify(payload),
460
+ signal: controller.signal,
461
+ });
462
+
463
+ if (RETRYABLE_STATUS_CODES.has(response.status) && retryCount === 0) {
464
+ this.oauthManager.invalidateCredentials();
465
+ return this.completeWithRetry(options, 1);
466
+ }
467
+
468
+ if (!response.ok) {
469
+ this.handleHttpError(response.status, await response.text());
470
+ }
471
+
472
+ const data = (await response.json()) as Record<string, unknown>;
473
+ return this.parseCompletionResponse(data);
474
+ } finally {
475
+ clearTimeout(timeoutId);
476
+ }
477
+ }
478
+
479
+ public async *completeStreaming(options: {
480
+ model: string;
481
+ messages: Message[];
482
+ generationConfig?: GenerationConfig;
483
+ thinkingConfig?: ThinkingConfig;
484
+ tools?: Tool[];
485
+ extraHeaders?: Record<string, string>;
486
+ }): AsyncGenerator<LLMChunk, void, unknown> {
487
+ yield* this.completeStreamingWithRetry(options, 0);
488
+ }
489
+
490
+ private async *completeStreamingWithRetry(
491
+ options: {
492
+ model: string;
493
+ messages: Message[];
494
+ generationConfig?: GenerationConfig;
495
+ thinkingConfig?: ThinkingConfig;
496
+ tools?: Tool[];
497
+ extraHeaders?: Record<string, string>;
498
+ },
499
+ retryCount: number
500
+ ): AsyncGenerator<LLMChunk, void, unknown> {
501
+ const headers = await this.getAuthHeaders(retryCount > 0);
502
+ if (options.extraHeaders) {
503
+ Object.assign(headers, options.extraHeaders);
504
+ }
505
+
506
+ const accessToken = headers['Authorization']!.replace('Bearer ', '');
507
+ const projectId = await this.ensureProjectId(accessToken);
508
+ const url = `${this.oauthManager.getApiEndpoint()}:streamGenerateContent?alt=sse`;
509
+
510
+ const payload = this.buildRequestPayload(
511
+ options.model,
512
+ options.messages,
513
+ options.generationConfig,
514
+ options.thinkingConfig,
515
+ options.tools,
516
+ projectId
517
+ );
518
+
519
+ const controller = new AbortController();
520
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
521
+
522
+ try {
523
+ const response = await fetch(url, {
524
+ method: 'POST',
525
+ headers,
526
+ body: JSON.stringify(payload),
527
+ signal: controller.signal,
528
+ });
529
+
530
+ if (RETRYABLE_STATUS_CODES.has(response.status) && retryCount === 0) {
531
+ this.oauthManager.invalidateCredentials();
532
+ yield* this.completeStreamingWithRetry(options, 1);
533
+ return;
534
+ }
535
+
536
+ if (!response.ok) {
537
+ this.handleHttpError(response.status, await response.text());
538
+ }
539
+
540
+ if (!response.body) {
541
+ throw new APIError('No response body', 500);
542
+ }
543
+
544
+ const reader = response.body.getReader();
545
+ const decoder = new TextDecoder();
546
+ let buffer = '';
547
+
548
+ while (true) {
549
+ const { done, value } = await reader.read();
550
+ if (done) break;
551
+
552
+ buffer += decoder.decode(value, { stream: true });
553
+ const lines = buffer.split('\n');
554
+ buffer = lines.pop() ?? '';
555
+
556
+ for (const line of lines) {
557
+ const trimmed = line.trim();
558
+ if (!trimmed || trimmed.startsWith(':')) continue;
559
+
560
+ if (trimmed.startsWith('data:')) {
561
+ const data = trimmed.slice(5).trim();
562
+ if (data === '[DONE]') continue;
563
+
564
+ try {
565
+ const parsed = JSON.parse(data) as Record<string, unknown>;
566
+ if (parsed['error']) {
567
+ const errorMsg =
568
+ typeof parsed['error'] === 'object'
569
+ ? ((parsed['error'] as Record<string, unknown>)['message'] as string)
570
+ : String(parsed['error']);
571
+ throw new APIError(errorMsg, 500);
572
+ }
573
+ yield this.parseCompletionResponse(parsed);
574
+ } catch (e) {
575
+ if (e instanceof APIError) throw e;
576
+ // Skip invalid JSON
577
+ }
578
+ }
579
+ }
580
+ }
581
+ } finally {
582
+ clearTimeout(timeoutId);
583
+ }
584
+ }
585
+
586
+ private handleHttpError(status: number, body: string): never {
587
+ let errorMsg = body;
588
+ try {
589
+ const errorData = JSON.parse(body) as Record<string, unknown>;
590
+ if (errorData['error']) {
591
+ const err = errorData['error'] as Record<string, unknown>;
592
+ errorMsg = (err['message'] as string) ?? body;
593
+ }
594
+ } catch {
595
+ // Use body as-is
596
+ }
597
+
598
+ if (status === 429) {
599
+ throw new RateLimitError(`Rate limit exceeded: ${errorMsg}`, 429, undefined, body);
600
+ } else if (status === 403) {
601
+ throw new PermissionDeniedError(`Permission denied: ${errorMsg}`, 403, body);
602
+ } else {
603
+ throw new APIError(`API error: ${errorMsg}`, status, body);
604
+ }
605
+ }
606
+
607
+ public async listModels(): Promise<string[]> {
608
+ const { GEMINI_CLI_MODELS } = await import('./types.js');
609
+ return Object.keys(GEMINI_CLI_MODELS);
610
+ }
611
+
612
+ public async close(): Promise<void> {
613
+ // No persistent connections to close in fetch-based implementation
614
+ }
615
+ }