@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,17 +1,14 @@
1
1
  import {
2
- Content,
3
- FunctionDeclaration,
4
2
  GenerateContentConfig,
5
3
  Tool as GoogleFunctionCallTool,
6
4
  GoogleGenAI,
7
5
  HttpOptions,
8
- Part,
9
- Type as SchemaType,
10
6
  ThinkingConfig,
11
7
  } from '@google/genai';
12
8
  import debug from 'debug';
13
9
 
14
10
  import { LobeRuntimeAI } from '../../core/BaseAI';
11
+ import { buildGoogleMessages, buildGoogleTools } from '../../core/contextBuilders/google';
15
12
  import { GoogleGenerativeAIStream, VertexAIStream } from '../../core/streams';
16
13
  import { LOBE_ERROR_KEY } from '../../core/streams/google';
17
14
  import {
@@ -20,8 +17,6 @@ import {
20
17
  ChatStreamPayload,
21
18
  GenerateObjectOptions,
22
19
  GenerateObjectPayload,
23
- OpenAIChatMessage,
24
- UserMessageContentPart,
25
20
  } from '../../types';
26
21
  import { AgentRuntimeErrorType } from '../../types/error';
27
22
  import { CreateImagePayload, CreateImageResponse } from '../../types/image';
@@ -29,12 +24,9 @@ import { AgentRuntimeError } from '../../utils/createError';
29
24
  import { debugStream } from '../../utils/debugStream';
30
25
  import { getModelPricing } from '../../utils/getModelPricing';
31
26
  import { parseGoogleErrorMessage } from '../../utils/googleErrorParser';
32
- import { imageUrlToBase64 } from '../../utils/imageToBase64';
33
27
  import { StreamingResponse } from '../../utils/response';
34
- import { safeParseJSON } from '../../utils/safeParseJSON';
35
- import { parseDataUri } from '../../utils/uriParser';
36
28
  import { createGoogleImage } from './createImage';
37
- import { createGoogleGenerateObject } from './generateObject';
29
+ import { createGoogleGenerateObject, createGoogleGenerateObjectWithTools } from './generateObject';
38
30
 
39
31
  const log = debug('model-runtime:google');
40
32
 
@@ -217,7 +209,7 @@ export class LobeGoogleAI implements LobeRuntimeAI {
217
209
  thinkingBudget: resolvedThinkingBudget,
218
210
  };
219
211
 
220
- const contents = await this.buildGoogleMessages(payload.messages);
212
+ const contents = await buildGoogleMessages(payload.messages);
221
213
 
222
214
  const controller = new AbortController();
223
215
  const originalSignal = options?.signal;
@@ -264,7 +256,7 @@ export class LobeGoogleAI implements LobeRuntimeAI {
264
256
  modelsDisableInstuction.has(model) || model.toLowerCase().includes('learnlm')
265
257
  ? undefined
266
258
  : thinkingConfig,
267
- tools: this.buildGoogleTools(payload.tools, payload),
259
+ tools: this.buildGoogleToolsWithSearch(payload.tools, payload),
268
260
  topP: payload.top_p,
269
261
  };
270
262
 
@@ -330,16 +322,31 @@ export class LobeGoogleAI implements LobeRuntimeAI {
330
322
  /**
331
323
  * Generate structured output using Google Gemini API
332
324
  * @see https://ai.google.dev/gemini-api/docs/structured-output
325
+ * @see https://ai.google.dev/gemini-api/docs/function-calling
333
326
  */
334
327
  async generateObject(payload: GenerateObjectPayload, options?: GenerateObjectOptions) {
335
328
  // Convert OpenAI messages to Google format
336
- const contents = await this.buildGoogleMessages(payload.messages);
329
+ const contents = await buildGoogleMessages(payload.messages);
330
+
331
+ // Handle tools-based structured output
332
+ if (payload.tools && payload.tools.length > 0) {
333
+ return createGoogleGenerateObjectWithTools(
334
+ this.client,
335
+ { contents, model: payload.model, tools: payload.tools },
336
+ options,
337
+ );
338
+ }
339
+
340
+ // Handle schema-based structured output
341
+ if (payload.schema) {
342
+ return createGoogleGenerateObject(
343
+ this.client,
344
+ { contents, model: payload.model, schema: payload.schema },
345
+ options,
346
+ );
347
+ }
337
348
 
338
- return createGoogleGenerateObject(
339
- this.client,
340
- { contents, model: payload.model, schema: payload.schema },
341
- options,
342
- );
349
+ return undefined;
343
350
  }
344
351
 
345
352
  private createEnhancedStream(originalStream: any, signal: AbortSignal): ReadableStream {
@@ -489,147 +496,7 @@ export class LobeGoogleAI implements LobeRuntimeAI {
489
496
  };
490
497
  }
491
498
 
492
- private convertContentToGooglePart = async (
493
- content: UserMessageContentPart,
494
- ): Promise<Part | undefined> => {
495
- switch (content.type) {
496
- default: {
497
- return undefined;
498
- }
499
-
500
- case 'text': {
501
- return { text: content.text };
502
- }
503
-
504
- case 'image_url': {
505
- const { mimeType, base64, type } = parseDataUri(content.image_url.url);
506
-
507
- if (type === 'base64') {
508
- if (!base64) {
509
- throw new TypeError("Image URL doesn't contain base64 data");
510
- }
511
-
512
- return {
513
- inlineData: { data: base64, mimeType: mimeType || 'image/png' },
514
- };
515
- }
516
-
517
- if (type === 'url') {
518
- const { base64, mimeType } = await imageUrlToBase64(content.image_url.url);
519
-
520
- return {
521
- inlineData: { data: base64, mimeType },
522
- };
523
- }
524
-
525
- throw new TypeError(`currently we don't support image url: ${content.image_url.url}`);
526
- }
527
-
528
- case 'video_url': {
529
- const { mimeType, base64, type } = parseDataUri(content.video_url.url);
530
-
531
- if (type === 'base64') {
532
- if (!base64) {
533
- throw new TypeError("Video URL doesn't contain base64 data");
534
- }
535
-
536
- return {
537
- inlineData: { data: base64, mimeType: mimeType || 'video/mp4' },
538
- };
539
- }
540
-
541
- if (type === 'url') {
542
- // For video URLs, we need to fetch and convert to base64
543
- // Note: This might need size/duration limits for practical use
544
- const response = await fetch(content.video_url.url);
545
- const arrayBuffer = await response.arrayBuffer();
546
- const base64 = Buffer.from(arrayBuffer).toString('base64');
547
- const mimeType = response.headers.get('content-type') || 'video/mp4';
548
-
549
- return {
550
- inlineData: { data: base64, mimeType },
551
- };
552
- }
553
-
554
- throw new TypeError(`currently we don't support video url: ${content.video_url.url}`);
555
- }
556
- }
557
- };
558
-
559
- private convertOAIMessagesToGoogleMessage = async (
560
- message: OpenAIChatMessage,
561
- toolCallNameMap?: Map<string, string>,
562
- ): Promise<Content> => {
563
- const content = message.content as string | UserMessageContentPart[];
564
- if (!!message.tool_calls) {
565
- return {
566
- parts: message.tool_calls.map<Part>((tool) => ({
567
- functionCall: {
568
- args: safeParseJSON(tool.function.arguments)!,
569
- name: tool.function.name,
570
- },
571
- })),
572
- role: 'model',
573
- };
574
- }
575
-
576
- // 将 tool_call result 转成 functionResponse part
577
- if (message.role === 'tool' && toolCallNameMap && message.tool_call_id) {
578
- const functionName = toolCallNameMap.get(message.tool_call_id);
579
- if (functionName) {
580
- return {
581
- parts: [
582
- {
583
- functionResponse: {
584
- name: functionName,
585
- response: { result: message.content },
586
- },
587
- },
588
- ],
589
- role: 'user',
590
- };
591
- }
592
- }
593
-
594
- const getParts = async () => {
595
- if (typeof content === 'string') return [{ text: content }];
596
-
597
- const parts = await Promise.all(
598
- content.map(async (c) => await this.convertContentToGooglePart(c)),
599
- );
600
- return parts.filter(Boolean) as Part[];
601
- };
602
-
603
- return {
604
- parts: await getParts(),
605
- role: message.role === 'assistant' ? 'model' : 'user',
606
- };
607
- };
608
-
609
- // convert messages from the OpenAI format to Google GenAI SDK
610
- private buildGoogleMessages = async (messages: OpenAIChatMessage[]): Promise<Content[]> => {
611
- const toolCallNameMap = new Map<string, string>();
612
- messages.forEach((message) => {
613
- if (message.role === 'assistant' && message.tool_calls) {
614
- message.tool_calls.forEach((toolCall) => {
615
- if (toolCall.type === 'function') {
616
- toolCallNameMap.set(toolCall.id, toolCall.function.name);
617
- }
618
- });
619
- }
620
- });
621
-
622
- const pools = messages
623
- .filter((message) => message.role !== 'function')
624
- .map(async (msg) => await this.convertOAIMessagesToGoogleMessage(msg, toolCallNameMap));
625
-
626
- const contents = await Promise.all(pools);
627
-
628
- // 筛除空消息: contents.parts must not be empty.
629
- return contents.filter((content: Content) => content.parts && content.parts.length > 0);
630
- };
631
-
632
- private buildGoogleTools(
499
+ private buildGoogleToolsWithSearch(
633
500
  tools: ChatCompletionTool[] | undefined,
634
501
  payload?: ChatStreamPayload,
635
502
  ): GoogleFunctionCallTool[] | undefined {
@@ -640,7 +507,7 @@ export class LobeGoogleAI implements LobeRuntimeAI {
640
507
 
641
508
  // 如果已经有 tool_calls,优先处理 function declarations
642
509
  if (hasToolCalls && hasFunctionTools) {
643
- return this.buildFunctionDeclarations(tools);
510
+ return buildGoogleTools(tools);
644
511
  }
645
512
 
646
513
  // 构建并返回搜索相关工具(搜索工具不能与 FunctionCall 同时使用)
@@ -655,41 +522,8 @@ export class LobeGoogleAI implements LobeRuntimeAI {
655
522
  }
656
523
 
657
524
  // 最后考虑 function declarations
658
- return this.buildFunctionDeclarations(tools);
525
+ return buildGoogleTools(tools);
659
526
  }
660
-
661
- private buildFunctionDeclarations(
662
- tools: ChatCompletionTool[] | undefined,
663
- ): GoogleFunctionCallTool[] | undefined {
664
- if (!tools || tools.length === 0) return;
665
-
666
- return [
667
- {
668
- functionDeclarations: tools.map((tool) => this.convertToolToGoogleTool(tool)),
669
- },
670
- ];
671
- }
672
-
673
- private convertToolToGoogleTool = (tool: ChatCompletionTool): FunctionDeclaration => {
674
- const functionDeclaration = tool.function;
675
- const parameters = functionDeclaration.parameters;
676
- // refs: https://github.com/lobehub/lobe-chat/pull/5002
677
- const properties =
678
- parameters?.properties && Object.keys(parameters.properties).length > 0
679
- ? parameters.properties
680
- : { dummy: { type: 'string' } }; // dummy property to avoid empty object
681
-
682
- return {
683
- description: functionDeclaration.description,
684
- name: functionDeclaration.name,
685
- parameters: {
686
- description: parameters?.description,
687
- properties: properties,
688
- required: parameters?.required,
689
- type: SchemaType.OBJECT,
690
- },
691
- };
692
- };
693
527
  }
694
528
 
695
529
  export default LobeGoogleAI;
@@ -59,10 +59,10 @@ describe('LobeOpenAI', () => {
59
59
  const apiError = new OpenAI.APIError(
60
60
  400,
61
61
  {
62
- status: 400,
63
62
  error: {
64
63
  message: 'Bad Request',
65
64
  },
65
+ status: 400,
66
66
  },
67
67
  'Error message',
68
68
  {},
@@ -178,13 +178,13 @@ describe('LobeOpenAI', () => {
178
178
  } catch (e) {
179
179
  expect(e).toEqual({
180
180
  endpoint: 'https://api.openai.com/v1',
181
- errorType: 'AgentRuntimeError',
182
- provider: 'openai',
183
181
  error: {
184
- name: genericError.name,
185
182
  cause: genericError.cause,
186
183
  message: genericError.message,
184
+ name: genericError.name,
187
185
  },
186
+ errorType: 'AgentRuntimeError',
187
+ provider: 'openai',
188
188
  });
189
189
  }
190
190
  });
@@ -261,10 +261,10 @@ describe('LobeOpenAI', () => {
261
261
 
262
262
  it('should use responses API when enabledSearch is true', async () => {
263
263
  const payload = {
264
+ enabledSearch: true,
264
265
  messages: [{ content: 'Hello', role: 'user' as const }],
265
266
  model: 'gpt-4o',
266
267
  temperature: 0.7,
267
- enabledSearch: true,
268
268
  };
269
269
 
270
270
  await instance.chat(payload);
@@ -275,12 +275,12 @@ describe('LobeOpenAI', () => {
275
275
 
276
276
  it('should handle -search- models with stripped parameters', async () => {
277
277
  const payload = {
278
+ frequency_penalty: 0.5,
278
279
  messages: [{ content: 'Hello', role: 'user' as const }],
279
280
  model: 'gpt-4o-search-2024',
281
+ presence_penalty: 0.3,
280
282
  temperature: 0.7,
281
283
  top_p: 0.9,
282
- frequency_penalty: 0.5,
283
- presence_penalty: 0.3,
284
284
  };
285
285
 
286
286
  await instance.chat(payload);
@@ -296,12 +296,12 @@ describe('LobeOpenAI', () => {
296
296
 
297
297
  it('should handle regular models with all parameters', async () => {
298
298
  const payload = {
299
+ frequency_penalty: 0.5,
299
300
  messages: [{ content: 'Hello', role: 'user' as const }],
300
301
  model: 'gpt-4o',
302
+ presence_penalty: 0.3,
301
303
  temperature: 0.7,
302
304
  top_p: 0.9,
303
- frequency_penalty: 0.5,
304
- presence_penalty: 0.3,
305
305
  };
306
306
 
307
307
  await instance.chat(payload);
@@ -319,18 +319,19 @@ describe('LobeOpenAI', () => {
319
319
  describe('responses.handlePayload', () => {
320
320
  it('should add web_search tool when enabledSearch is true', async () => {
321
321
  const payload = {
322
+ enabledSearch: true,
322
323
  messages: [{ content: 'Hello', role: 'user' as const }],
323
- model: 'gpt-4o', // 使用常规模型,通过 enabledSearch 触发 responses API
324
+ model: 'gpt-4o',
325
+ // 使用常规模型,通过 enabledSearch 触发 responses API
324
326
  temperature: 0.7,
325
- enabledSearch: true,
326
- tools: [{ type: 'function' as const, function: { name: 'test', description: 'test' } }],
327
+ tools: [{ function: { description: 'test', name: 'test' }, type: 'function' as const }],
327
328
  };
328
329
 
329
330
  await instance.chat(payload);
330
331
 
331
332
  const createCall = (instance['client'].responses.create as Mock).mock.calls[0][0];
332
333
  expect(createCall.tools).toEqual([
333
- { type: 'function', name: 'test', description: 'test' },
334
+ { description: 'test', name: 'test', type: 'function' },
334
335
  { type: 'web_search' },
335
336
  ]);
336
337
  });
@@ -339,10 +340,10 @@ describe('LobeOpenAI', () => {
339
340
  // Note: oaiSearchContextSize is read at module load time, not runtime
340
341
  // This test verifies the tool structure is correct when the env var would be set
341
342
  const payload = {
343
+ enabledSearch: true,
342
344
  messages: [{ content: 'Hello', role: 'user' as const }],
343
345
  model: 'gpt-4o',
344
346
  temperature: 0.7,
345
- enabledSearch: true,
346
347
  };
347
348
 
348
349
  await instance.chat(payload);
@@ -358,8 +359,8 @@ describe('LobeOpenAI', () => {
358
359
  const payload = {
359
360
  messages: [{ content: 'Hello', role: 'user' as const }],
360
361
  model: 'computer-use-preview',
361
- temperature: 0.7,
362
362
  reasoning: { effort: 'medium' },
363
+ temperature: 0.7,
363
364
  };
364
365
 
365
366
  await instance.chat(payload);
@@ -393,7 +394,7 @@ describe('LobeOpenAI', () => {
393
394
  await instance.chat(payload);
394
395
 
395
396
  const createCall = (instance['client'].responses.create as Mock).mock.calls[0][0];
396
- expect(createCall.reasoning).toEqual({ summary: 'auto', effort: 'high' });
397
+ expect(createCall.reasoning).toEqual({ effort: 'high', summary: 'auto' });
397
398
  });
398
399
 
399
400
  it('should set reasoning.effort to high for gpt-5-pro-2025-10-06 models', async () => {
@@ -406,7 +407,7 @@ describe('LobeOpenAI', () => {
406
407
  await instance.chat(payload);
407
408
 
408
409
  const createCall = (instance['client'].responses.create as Mock).mock.calls[0][0];
409
- expect(createCall.reasoning).toEqual({ summary: 'auto', effort: 'high' });
410
+ expect(createCall.reasoning).toEqual({ effort: 'high', summary: 'auto' });
410
411
  });
411
412
  });
412
413
 
@@ -1,4 +1,4 @@
1
- import { ChatCompletionFunctions } from './chat';
1
+ import { ChatCompletionTool } from './chat';
2
2
 
3
3
  interface GenerateObjectMessage {
4
4
  content: string;
@@ -6,7 +6,7 @@ interface GenerateObjectMessage {
6
6
  role: 'user' | 'system' | 'assistant';
7
7
  }
8
8
 
9
- interface GenerateObjectSchema {
9
+ export interface GenerateObjectSchema {
10
10
  description?: string;
11
11
  name: string;
12
12
  schema: {
@@ -23,8 +23,7 @@ export interface GenerateObjectPayload {
23
23
  model: string;
24
24
  responseApi?: boolean;
25
25
  schema?: GenerateObjectSchema;
26
- systemRole?: string;
27
- tools?: ChatCompletionFunctions[];
26
+ tools?: ChatCompletionTool[];
28
27
  }
29
28
 
30
29
  export interface GenerateObjectOptions {
@@ -75,7 +75,6 @@ export const StructureOutputSchema = z.object({
75
75
  model: z.string(),
76
76
  provider: z.string(),
77
77
  schema: StructureSchema.optional(),
78
- systemRole: z.string().optional(),
79
78
  tools: z
80
79
  .array(z.object({ function: LobeUniformToolSchema, type: z.literal('function') }))
81
80
  .optional(),
@@ -5,8 +5,6 @@ import { pino } from '@/libs/logger';
5
5
  import { createEdgeContext } from '@/libs/trpc/edge/context';
6
6
  import { edgeRouter } from '@/server/routers/edge';
7
7
 
8
- export const runtime = 'edge';
9
-
10
8
  const handler = (req: NextRequest) =>
11
9
  fetchRequestHandler({
12
10
  /**
@@ -1,5 +1,6 @@
1
1
  /**
2
- * This file contains the edge router of Lobe Chat tRPC-backend
2
+ * @deprecated
3
+ * TODO: it will be remove in V2.0
3
4
  */
4
5
  import { publicProcedure, router } from '@/libs/trpc/edge';
5
6
 
@@ -60,8 +60,7 @@ export const aiChatRouter = router({
60
60
  messages: input.messages,
61
61
  model: input.model,
62
62
  schema: input.schema,
63
- systemRole: input.systemRole,
64
- tools: input.tools?.map((item) => item.function),
63
+ tools: input.tools,
65
64
  });
66
65
 
67
66
  log('generateObject completed, result: %O', result);
@@ -28,6 +28,7 @@ import { sessionRouter } from './session';
28
28
  import { sessionGroupRouter } from './sessionGroup';
29
29
  import { threadRouter } from './thread';
30
30
  import { topicRouter } from './topic';
31
+ import { uploadRouter } from './upload';
31
32
  import { userRouter } from './user';
32
33
 
33
34
  export const lambdaRouter = router({
@@ -57,6 +58,7 @@ export const lambdaRouter = router({
57
58
  sessionGroup: sessionGroupRouter,
58
59
  thread: threadRouter,
59
60
  topic: topicRouter,
61
+ upload: uploadRouter,
60
62
  user: userRouter,
61
63
  });
62
64
 
@@ -0,0 +1,16 @@
1
+ import { z } from 'zod';
2
+
3
+ import { authedProcedure, router } from '@/libs/trpc/lambda';
4
+ import { S3 } from '@/server/modules/S3';
5
+
6
+ export const uploadRouter = router({
7
+ createS3PreSignedUrl: authedProcedure
8
+ .input(z.object({ pathname: z.string() }))
9
+ .mutation(async ({ input }) => {
10
+ const s3 = new S3();
11
+
12
+ return await s3.createPreSignedUrl(input.pathname);
13
+ }),
14
+ });
15
+
16
+ export type FileRouter = typeof uploadRouter;