@lobehub/chat 1.138.3 → 1.138.5

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 (24) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/changelog/v1.json +18 -0
  3. package/package.json +1 -1
  4. package/packages/database/src/repositories/aiInfra/index.test.ts +656 -0
  5. package/packages/model-runtime/src/core/contextBuilders/google.test.ts +585 -0
  6. package/packages/model-runtime/src/core/contextBuilders/google.ts +201 -0
  7. package/packages/model-runtime/src/core/openaiCompatibleFactory/index.test.ts +191 -179
  8. package/packages/model-runtime/src/core/openaiCompatibleFactory/index.ts +305 -47
  9. package/packages/model-runtime/src/providers/anthropic/generateObject.test.ts +93 -84
  10. package/packages/model-runtime/src/providers/anthropic/generateObject.ts +3 -3
  11. package/packages/model-runtime/src/providers/google/generateObject.test.ts +588 -83
  12. package/packages/model-runtime/src/providers/google/generateObject.ts +104 -6
  13. package/packages/model-runtime/src/providers/google/index.test.ts +0 -395
  14. package/packages/model-runtime/src/providers/google/index.ts +28 -194
  15. package/packages/model-runtime/src/providers/openai/index.test.ts +18 -17
  16. package/packages/model-runtime/src/types/structureOutput.ts +3 -4
  17. package/packages/types/src/aiChat.ts +0 -1
  18. package/src/app/(backend)/trpc/edge/[trpc]/route.ts +0 -2
  19. package/src/server/routers/edge/index.ts +2 -1
  20. package/src/server/routers/lambda/aiChat.ts +1 -2
  21. package/src/server/routers/lambda/index.ts +2 -0
  22. package/src/server/routers/lambda/upload.ts +16 -0
  23. package/src/services/__tests__/upload.test.ts +266 -18
  24. package/src/services/upload.ts +2 -2
@@ -1,9 +1,15 @@
1
- import { GenerateContentConfig, GoogleGenAI, Type as SchemaType } from '@google/genai';
1
+ import {
2
+ FunctionCallingConfigMode,
3
+ GenerateContentConfig,
4
+ GoogleGenAI,
5
+ Type as SchemaType,
6
+ } from '@google/genai';
2
7
  import Debug from 'debug';
3
8
 
4
- import { GenerateObjectOptions } from '../../types';
9
+ import { buildGoogleTool } from '../../core/contextBuilders/google';
10
+ import { ChatCompletionTool, GenerateObjectOptions, GenerateObjectSchema } from '../../types';
5
11
 
6
- const debug = Debug('mode-runtime:google:generateObject');
12
+ const debug = Debug('lobe-mode-runtime:google:generateObject');
7
13
 
8
14
  enum HarmCategory {
9
15
  HARM_CATEGORY_DANGEROUS_CONTENT = 'HARM_CATEGORY_DANGEROUS_CONTENT',
@@ -54,7 +60,7 @@ const convertType = (type: string): SchemaType => {
54
60
  /**
55
61
  * Convert OpenAI JSON schema to Google Gemini schema format
56
62
  */
57
- export const convertOpenAISchemaToGoogleSchema = (openAISchema: any): any => {
63
+ export const convertOpenAISchemaToGoogleSchema = (openAISchema: GenerateObjectSchema): any => {
58
64
  const convertSchema = (schema: any): any => {
59
65
  if (!schema) return schema;
60
66
 
@@ -92,7 +98,7 @@ export const convertOpenAISchemaToGoogleSchema = (openAISchema: any): any => {
92
98
  return converted;
93
99
  };
94
100
 
95
- return convertSchema(openAISchema);
101
+ return convertSchema(openAISchema.schema);
96
102
  };
97
103
 
98
104
  /**
@@ -104,7 +110,7 @@ export const createGoogleGenerateObject = async (
104
110
  payload: {
105
111
  contents: any[];
106
112
  model: string;
107
- schema: any;
113
+ schema: GenerateObjectSchema;
108
114
  },
109
115
  options?: GenerateObjectOptions,
110
116
  ) => {
@@ -175,3 +181,95 @@ export const createGoogleGenerateObject = async (
175
181
  return undefined;
176
182
  }
177
183
  };
184
+
185
+ /**
186
+ * Generate structured output using Google Gemini API with tools calling
187
+ * @see https://ai.google.dev/gemini-api/docs/function-calling
188
+ */
189
+ export const createGoogleGenerateObjectWithTools = async (
190
+ client: GoogleGenAI,
191
+ payload: {
192
+ contents: any[];
193
+ model: string;
194
+ tools: ChatCompletionTool[];
195
+ },
196
+ options?: GenerateObjectOptions,
197
+ ) => {
198
+ const { tools, contents, model } = payload;
199
+
200
+ debug('createGoogleGenerateObjectWithTools started', {
201
+ contentsLength: contents.length,
202
+ model,
203
+ toolsCount: tools.length,
204
+ });
205
+
206
+ // Convert tools to Google FunctionDeclaration format
207
+ const functionDeclarations = tools.map(buildGoogleTool);
208
+ debug('Tools conversion completed', { functionDeclarations });
209
+
210
+ const config: GenerateContentConfig = {
211
+ abortSignal: options?.signal,
212
+ // avoid wide sensitive words
213
+ safetySettings: [
214
+ {
215
+ category: HarmCategory.HARM_CATEGORY_HATE_SPEECH,
216
+ threshold: getThreshold(model),
217
+ },
218
+ {
219
+ category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
220
+ threshold: getThreshold(model),
221
+ },
222
+ {
223
+ category: HarmCategory.HARM_CATEGORY_HARASSMENT,
224
+ threshold: getThreshold(model),
225
+ },
226
+ {
227
+ category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
228
+ threshold: getThreshold(model),
229
+ },
230
+ ],
231
+ // Force tool calling with 'any' mode
232
+ toolConfig: {
233
+ functionCallingConfig: {
234
+ mode: FunctionCallingConfigMode.ANY,
235
+ },
236
+ },
237
+ tools: [{ functionDeclarations }],
238
+ };
239
+
240
+ debug('Config prepared', {
241
+ hasAbortSignal: !!config.abortSignal,
242
+ hasSafetySettings: !!config.safetySettings,
243
+ hasTools: !!config.tools,
244
+ model,
245
+ });
246
+
247
+ const response = await client.models.generateContent({
248
+ config,
249
+ contents,
250
+ model,
251
+ });
252
+
253
+ debug('API response received', {
254
+ candidatesCount: response.candidates?.length,
255
+ hasContent: !!response.candidates?.[0]?.content,
256
+ });
257
+
258
+ // Extract function calls from response
259
+ const candidate = response.candidates?.[0];
260
+ if (!candidate?.content?.parts) {
261
+ debug('no content parts in response');
262
+ return undefined;
263
+ }
264
+
265
+ const functionCalls = candidate.content.parts
266
+ .filter((part) => part.functionCall)
267
+ .map((part) => ({
268
+ arguments: part.functionCall!.args,
269
+ name: part.functionCall!.name,
270
+ }));
271
+
272
+ debug('extracted function calls', { count: functionCalls.length, functionCalls });
273
+
274
+ return functionCalls.length > 0 ? functionCalls : undefined;
275
+ };
@@ -432,401 +432,6 @@ describe('LobeGoogleAI', () => {
432
432
  });
433
433
 
434
434
  describe('private method', () => {
435
- describe('convertContentToGooglePart', () => {
436
- it('should handle text type messages', async () => {
437
- const result = await instance['convertContentToGooglePart']({
438
- type: 'text',
439
- text: 'Hello',
440
- });
441
- expect(result).toEqual({ text: 'Hello' });
442
- });
443
- it('should handle thinking type messages', async () => {
444
- const result = await instance['convertContentToGooglePart']({
445
- type: 'thinking',
446
- thinking: 'Hello',
447
- signature: 'abc',
448
- });
449
- expect(result).toEqual(undefined);
450
- });
451
-
452
- it('should handle base64 type images', async () => {
453
- const base64Image =
454
- 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==';
455
- const result = await instance['convertContentToGooglePart']({
456
- type: 'image_url',
457
- image_url: { url: base64Image },
458
- });
459
-
460
- expect(result).toEqual({
461
- inlineData: {
462
- data: 'iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==',
463
- mimeType: 'image/png',
464
- },
465
- });
466
- });
467
-
468
- it('should handle URL type images', async () => {
469
- const imageUrl = 'http://example.com/image.png';
470
- const mockBase64 = 'mockBase64Data';
471
-
472
- // Mock the imageUrlToBase64 function
473
- vi.spyOn(imageToBase64Module, 'imageUrlToBase64').mockResolvedValueOnce({
474
- base64: mockBase64,
475
- mimeType: 'image/png',
476
- });
477
-
478
- const result = await instance['convertContentToGooglePart']({
479
- type: 'image_url',
480
- image_url: { url: imageUrl },
481
- });
482
-
483
- expect(result).toEqual({
484
- inlineData: {
485
- data: mockBase64,
486
- mimeType: 'image/png',
487
- },
488
- });
489
-
490
- expect(imageToBase64Module.imageUrlToBase64).toHaveBeenCalledWith(imageUrl);
491
- });
492
-
493
- it('should throw TypeError for unsupported image URL types', async () => {
494
- const unsupportedImageUrl = 'unsupported://example.com/image.png';
495
-
496
- await expect(
497
- instance['convertContentToGooglePart']({
498
- type: 'image_url',
499
- image_url: { url: unsupportedImageUrl },
500
- }),
501
- ).rejects.toThrow(TypeError);
502
- });
503
- });
504
-
505
- describe('buildGoogleMessages', () => {
506
- it('get default result with gemini-pro', async () => {
507
- const messages: OpenAIChatMessage[] = [{ content: 'Hello', role: 'user' }];
508
-
509
- const contents = await instance['buildGoogleMessages'](messages);
510
-
511
- expect(contents).toHaveLength(1);
512
- expect(contents).toEqual([{ parts: [{ text: 'Hello' }], role: 'user' }]);
513
- });
514
-
515
- it('should not modify the length if model is gemini-1.5-pro', async () => {
516
- const messages: OpenAIChatMessage[] = [
517
- { content: 'Hello', role: 'user' },
518
- { content: 'Hi', role: 'assistant' },
519
- ];
520
-
521
- const contents = await instance['buildGoogleMessages'](messages);
522
-
523
- expect(contents).toHaveLength(2);
524
- expect(contents).toEqual([
525
- { parts: [{ text: 'Hello' }], role: 'user' },
526
- { parts: [{ text: 'Hi' }], role: 'model' },
527
- ]);
528
- });
529
-
530
- it('should use specified model when images are included in messages', async () => {
531
- const messages: OpenAIChatMessage[] = [
532
- {
533
- content: [
534
- { type: 'text', text: 'Hello' },
535
- { type: 'image_url', image_url: { url: 'data:image/png;base64,...' } },
536
- ],
537
- role: 'user',
538
- },
539
- ];
540
-
541
- // Call the buildGoogleMessages method
542
- const contents = await instance['buildGoogleMessages'](messages);
543
-
544
- expect(contents).toHaveLength(1);
545
- expect(contents).toEqual([
546
- {
547
- parts: [{ text: 'Hello' }, { inlineData: { data: '...', mimeType: 'image/png' } }],
548
- role: 'user',
549
- },
550
- ]);
551
- });
552
-
553
- it('should correctly convert function response message', async () => {
554
- const messages: OpenAIChatMessage[] = [
555
- {
556
- content: '',
557
- role: 'assistant',
558
- tool_calls: [
559
- {
560
- id: 'call_1',
561
- function: {
562
- name: 'get_current_weather',
563
- arguments: JSON.stringify({ location: 'London', unit: 'celsius' }),
564
- },
565
- type: 'function',
566
- },
567
- ],
568
- },
569
- {
570
- content: '{"success":true,"data":{"temperature":"14°C"}}',
571
- name: 'get_current_weather',
572
- role: 'tool',
573
- tool_call_id: 'call_1',
574
- },
575
- ];
576
-
577
- const contents = await instance['buildGoogleMessages'](messages);
578
- expect(contents).toHaveLength(2);
579
- expect(contents).toEqual([
580
- {
581
- parts: [
582
- {
583
- functionCall: {
584
- args: { location: 'London', unit: 'celsius' },
585
- name: 'get_current_weather',
586
- },
587
- },
588
- ],
589
- role: 'model',
590
- },
591
- {
592
- parts: [
593
- {
594
- functionResponse: {
595
- name: 'get_current_weather',
596
- response: { result: '{"success":true,"data":{"temperature":"14°C"}}' },
597
- },
598
- },
599
- ],
600
- role: 'user',
601
- },
602
- ]);
603
- });
604
- });
605
-
606
- describe('buildGoogleTools', () => {
607
- it('should return undefined when tools is undefined or empty', () => {
608
- expect(instance['buildGoogleTools'](undefined)).toBeUndefined();
609
- expect(instance['buildGoogleTools']([])).toBeUndefined();
610
- });
611
-
612
- it('should correctly convert ChatCompletionTool to GoogleFunctionCallTool', () => {
613
- const tools: OpenAI.ChatCompletionTool[] = [
614
- {
615
- function: {
616
- name: 'testTool',
617
- description: 'A test tool',
618
- parameters: {
619
- type: 'object',
620
- properties: {
621
- param1: { type: 'string' },
622
- param2: { type: 'number' },
623
- },
624
- required: ['param1'],
625
- },
626
- },
627
- type: 'function',
628
- },
629
- ];
630
-
631
- const googleTools = instance['buildGoogleTools'](tools);
632
-
633
- expect(googleTools).toHaveLength(1);
634
- expect((googleTools![0] as Tool).functionDeclarations![0]).toEqual({
635
- name: 'testTool',
636
- description: 'A test tool',
637
- parameters: {
638
- type: 'OBJECT',
639
- properties: {
640
- param1: { type: 'string' },
641
- param2: { type: 'number' },
642
- },
643
- required: ['param1'],
644
- },
645
- });
646
- });
647
-
648
- it('should also add tools when tool_calls exists', () => {
649
- const tools: OpenAI.ChatCompletionTool[] = [
650
- {
651
- function: {
652
- name: 'testTool',
653
- description: 'A test tool',
654
- parameters: {
655
- type: 'object',
656
- properties: {
657
- param1: { type: 'string' },
658
- param2: { type: 'number' },
659
- },
660
- required: ['param1'],
661
- },
662
- },
663
- type: 'function',
664
- },
665
- ];
666
-
667
- const payload: ChatStreamPayload = {
668
- messages: [
669
- {
670
- role: 'user',
671
- content: '',
672
- tool_calls: [
673
- { function: { name: 'some_func', arguments: '' }, id: 'func_1', type: 'function' },
674
- ],
675
- },
676
- ],
677
- model: 'gemini-2.5-flash-preview-04-17',
678
- temperature: 1,
679
- };
680
-
681
- const googleTools = instance['buildGoogleTools'](tools, payload);
682
-
683
- expect(googleTools).toHaveLength(1);
684
- expect((googleTools![0] as Tool).functionDeclarations![0]).toEqual({
685
- name: 'testTool',
686
- description: 'A test tool',
687
- parameters: {
688
- type: 'OBJECT',
689
- properties: {
690
- param1: { type: 'string' },
691
- param2: { type: 'number' },
692
- },
693
- required: ['param1'],
694
- },
695
- });
696
- });
697
-
698
- it('should handle googleSearch', () => {
699
- const payload: ChatStreamPayload = {
700
- messages: [
701
- {
702
- role: 'user',
703
- content: '',
704
- },
705
- ],
706
- model: 'gemini-2.5-flash-preview-04-17',
707
- temperature: 1,
708
- enabledSearch: true,
709
- };
710
-
711
- const googleTools = instance['buildGoogleTools'](undefined, payload);
712
-
713
- expect(googleTools).toHaveLength(1);
714
- expect(googleTools![0] as Tool).toEqual({ googleSearch: {} });
715
- });
716
- });
717
-
718
- describe('convertOAIMessagesToGoogleMessage', () => {
719
- it('should correctly convert assistant message', async () => {
720
- const message: OpenAIChatMessage = {
721
- role: 'assistant',
722
- content: 'Hello',
723
- };
724
-
725
- const converted = await instance['convertOAIMessagesToGoogleMessage'](message);
726
-
727
- expect(converted).toEqual({
728
- role: 'model',
729
- parts: [{ text: 'Hello' }],
730
- });
731
- });
732
-
733
- it('should correctly convert user message', async () => {
734
- const message: OpenAIChatMessage = {
735
- role: 'user',
736
- content: 'Hi',
737
- };
738
-
739
- const converted = await instance['convertOAIMessagesToGoogleMessage'](message);
740
-
741
- expect(converted).toEqual({
742
- role: 'user',
743
- parts: [{ text: 'Hi' }],
744
- });
745
- });
746
-
747
- it('should correctly convert message with inline base64 image parts', async () => {
748
- const message: OpenAIChatMessage = {
749
- role: 'user',
750
- content: [
751
- { type: 'text', text: 'Check this image:' },
752
- { type: 'image_url', image_url: { url: 'data:image/png;base64,...' } },
753
- ],
754
- };
755
-
756
- const converted = await instance['convertOAIMessagesToGoogleMessage'](message);
757
-
758
- expect(converted).toEqual({
759
- role: 'user',
760
- parts: [
761
- { text: 'Check this image:' },
762
- { inlineData: { data: '...', mimeType: 'image/png' } },
763
- ],
764
- });
765
- });
766
- it.skip('should correctly convert message with image url parts', async () => {
767
- const message: OpenAIChatMessage = {
768
- role: 'user',
769
- content: [
770
- { type: 'text', text: 'Check this image:' },
771
- { type: 'image_url', image_url: { url: 'https://image-file.com' } },
772
- ],
773
- };
774
-
775
- const converted = await instance['convertOAIMessagesToGoogleMessage'](message);
776
-
777
- expect(converted).toEqual({
778
- role: 'user',
779
- parts: [
780
- { text: 'Check this image:' },
781
- { inlineData: { data: '...', mimeType: 'image/png' } },
782
- ],
783
- });
784
- });
785
-
786
- it('should correctly convert function call message', async () => {
787
- const message = {
788
- role: 'assistant',
789
- tool_calls: [
790
- {
791
- id: 'call_1',
792
- function: {
793
- name: 'get_current_weather',
794
- arguments: JSON.stringify({ location: 'London', unit: 'celsius' }),
795
- },
796
- type: 'function',
797
- },
798
- ],
799
- } as OpenAIChatMessage;
800
-
801
- const converted = await instance['convertOAIMessagesToGoogleMessage'](message);
802
- expect(converted).toEqual({
803
- role: 'model',
804
- parts: [
805
- {
806
- functionCall: {
807
- name: 'get_current_weather',
808
- args: { location: 'London', unit: 'celsius' },
809
- },
810
- },
811
- ],
812
- });
813
- });
814
-
815
- it('should correctly handle empty content', async () => {
816
- const message: OpenAIChatMessage = {
817
- role: 'user',
818
- content: '' as any, // explicitly set as empty string
819
- };
820
-
821
- const converted = await instance['convertOAIMessagesToGoogleMessage'](message);
822
-
823
- expect(converted).toEqual({
824
- role: 'user',
825
- parts: [{ text: '' }],
826
- });
827
- });
828
- });
829
-
830
435
  describe('createEnhancedStream', () => {
831
436
  it('should handle stream cancellation with data gracefully', async () => {
832
437
  const mockStream = (async function* () {