Skip to content

Commit e18dafd

Browse files
feat: 修复工具调用和流式响应处理
Co-authored-by: aider (vertex_ai/gemini-2.5-pro) <aider@aider.chat>
1 parent 0df1375 commit e18dafd

File tree

2 files changed

+155
-93
lines changed

2 files changed

+155
-93
lines changed

packages/mcp-server/src/bridge/stream-transformer.ts

Lines changed: 83 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -16,38 +16,66 @@ interface OpenAIDelta {
1616
}[];
1717
}
1818

19-
interface OpenAIChoice {
20-
index: number;
21-
delta: OpenAIDelta;
22-
finish_reason: string | null;
23-
}
24-
2519
interface OpenAIChunk {
2620
id: string;
2721
object: 'chat.completion.chunk';
2822
created: number;
2923
model: string;
30-
choices: OpenAIChoice[];
24+
choices: {
25+
index: number;
26+
delta: OpenAIDelta;
27+
finish_reason: string | null;
28+
}[];
3129
}
3230

33-
// --- 更新的转换器 ---
31+
type ToolCallState = {
32+
id: string;
33+
name: string;
34+
arguments: string;
35+
};
36+
37+
// --- 更新的、有状态的转换器 ---
3438
export function createOpenAIStreamTransformer(
3539
model: string,
3640
): TransformStream<GenerateContentResponse, Uint8Array> {
3741
const chatID = `chatcmpl-${randomUUID()}`;
3842
const creationTime = Math.floor(Date.now() / 1000);
3943
const encoder = new TextEncoder();
4044
let isFirstChunk = true;
45+
const toolCallStates: ToolCallState[] = [];
4146

42-
return new TransformStream({
43-
transform(chunk, controller) {
44-
const parts = chunk.candidates?.[0]?.content?.parts || [];
45-
const finishReason = chunk.candidates?.[0]?.finishReason;
47+
const createChunk = (
48+
delta: OpenAIDelta,
49+
finish_reason: string | null = null,
50+
): OpenAIChunk => ({
51+
id: chatID,
52+
object: 'chat.completion.chunk',
53+
created: creationTime,
54+
model: model,
55+
choices: [
56+
{
57+
index: 0,
58+
delta,
59+
finish_reason,
60+
},
61+
],
62+
});
4663

47-
let hasContent = false;
64+
const enqueueChunk = (
65+
controller: TransformStreamDefaultController<Uint8Array>,
66+
chunk: OpenAIChunk,
67+
) => {
68+
const sseString = `data: ${JSON.stringify(chunk)}\n\n`;
69+
controller.enqueue(encoder.encode(sseString));
70+
};
71+
72+
return new TransformStream({
73+
transform(geminiChunk, controller) {
74+
const parts = geminiChunk.candidates?.[0]?.content?.parts || [];
75+
const finishReason = geminiChunk.candidates?.[0]?.finishReason;
4876

4977
for (const part of parts) {
50-
const delta: OpenAIDelta = {};
78+
let delta: OpenAIDelta = {};
5179

5280
if (isFirstChunk) {
5381
delta.role = 'assistant';
@@ -56,73 +84,67 @@ export function createOpenAIStreamTransformer(
5684

5785
if (part.text) {
5886
delta.content = part.text;
59-
hasContent = true;
87+
enqueueChunk(controller, createChunk(delta));
6088
}
6189

6290
if (part.functionCall) {
6391
const fc = part.functionCall;
64-
const callId = `call_${randomUUID()}`; // 为每个调用生成一个唯一的ID
92+
const callId = `call_${randomUUID()}`;
6593

66-
// OpenAI的流式工具调用是分块的,我们这里简化为一次性发送
67-
// Gemini通常也是一次性返回一个完整的functionCall
68-
delta.tool_calls = [
69-
{
70-
index: 0, // 假设只有一个工具调用
71-
id: callId,
72-
type: 'function',
73-
function: {
74-
name: fc.name,
75-
arguments: JSON.stringify(fc.args), // 参数必须是字符串
94+
// 模拟分块发送 tool_calls
95+
// 1. 发送带有 name 的块
96+
const nameDelta: OpenAIDelta = {
97+
tool_calls: [
98+
{
99+
index: toolCallStates.length,
100+
id: callId,
101+
type: 'function',
102+
function: { name: fc.name, arguments: '' },
76103
},
77-
},
78-
];
79-
hasContent = true;
80-
}
104+
],
105+
};
106+
if (isFirstChunk) {
107+
nameDelta.role = 'assistant';
108+
isFirstChunk = false;
109+
}
110+
enqueueChunk(controller, createChunk(nameDelta));
81111

82-
if (hasContent) {
83-
const openAIChunk: OpenAIChunk = {
84-
id: chatID,
85-
object: 'chat.completion.chunk',
86-
created: creationTime,
87-
model: model,
88-
choices: [
112+
// 2. 发送带有 arguments 的块
113+
const argsDelta: OpenAIDelta = {
114+
tool_calls: [
89115
{
90-
index: 0,
91-
delta: delta,
92-
finish_reason: null,
116+
index: toolCallStates.length,
117+
id: callId,
118+
type: 'function',
119+
function: { arguments: JSON.stringify(fc.args) },
93120
},
94121
],
95122
};
96-
const sseString = `data: ${JSON.stringify(openAIChunk)}\n\n`;
97-
controller.enqueue(encoder.encode(sseString));
123+
enqueueChunk(controller, createChunk(argsDelta));
124+
125+
toolCallStates.push({
126+
id: callId,
127+
name: fc.name,
128+
arguments: JSON.stringify(fc.args),
129+
});
98130
}
99131
}
100132

101-
// 如果有 finishReason,发送一个带有 finish_reason 的块
102133
if (
103134
finishReason &&
104-
finishReason !== 'FINISH_REASON_UNSPECIFIED'
135+
finishReason !== 'FINISH_REASON_UNSPECIFIED' &&
136+
finishReason !== 'NOT_SET'
105137
) {
106-
const finishDelta: OpenAIDelta = {};
107-
const openAIChunk: OpenAIChunk = {
108-
id: chatID,
109-
object: 'chat.completion.chunk',
110-
created: creationTime,
111-
model: model,
112-
choices: [
113-
{
114-
index: 0,
115-
delta: finishDelta,
116-
finish_reason: finishReason === 'STOP' ? 'stop' : 'tool_calls',
117-
},
118-
],
119-
};
120-
const sseString = `data: ${JSON.stringify(openAIChunk)}\n\n`;
121-
controller.enqueue(encoder.encode(sseString));
138+
const reason =
139+
finishReason === 'STOP'
140+
? 'stop'
141+
: finishReason === 'TOOL_CALL'
142+
? 'tool_calls'
143+
: finishReason.toLowerCase();
144+
enqueueChunk(controller, createChunk({}, reason));
122145
}
123146
},
124147
flush(controller) {
125-
// 流结束时,发送 [DONE] 消息
126148
const doneString = `data: [DONE]\n\n`;
127149
controller.enqueue(encoder.encode(doneString));
128150
},

packages/mcp-server/src/gemini-client.ts

Lines changed: 72 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,18 @@
55
*/
66

77
import { type Config, GeminiChat } from '@google/gemini-cli-core';
8-
import { type Content, type Part, type Tool } from '@google/genai';
9-
import { type OpenAIMessage, type MessageContentPart } from './types.js';
8+
import {
9+
type Content,
10+
type Part,
11+
type Tool,
12+
type FunctionDeclaration,
13+
type GenerateContentConfig,
14+
} from '@google/genai';
15+
import {
16+
type OpenAIMessage,
17+
type MessageContentPart,
18+
type OpenAIChatCompletionRequest,
19+
} from './types.js';
1020

1121
export class GeminiApiClient {
1222
private readonly config: Config;
@@ -17,13 +27,51 @@ export class GeminiApiClient {
1727
this.contentGenerator = this.config.getGeminiClient().getContentGenerator();
1828
}
1929

30+
/**
31+
* 将 OpenAI 的工具定义转换为 Gemini 的工具定义。
32+
*/
33+
private convertOpenAIToolsToGemini(
34+
openAITools?: OpenAIChatCompletionRequest['tools'],
35+
): Tool[] | undefined {
36+
if (!openAITools || openAITools.length === 0) {
37+
return undefined;
38+
}
39+
40+
const functionDeclarations: FunctionDeclaration[] = openAITools
41+
.filter((tool) => tool.type === 'function' && tool.function)
42+
.map((tool) => ({
43+
name: tool.function.name,
44+
description: tool.function.description,
45+
parameters: tool.function.parameters,
46+
}));
47+
48+
if (functionDeclarations.length === 0) {
49+
return undefined;
50+
}
51+
52+
return [{ functionDeclarations }];
53+
}
54+
2055
/**
2156
* 将 OpenAI 格式的消息转换为 Gemini 格式的 Content 对象。
22-
* 这个函数现在能处理文本和图片(多模态)输入。
2357
*/
2458
private openAIMessageToGemini(msg: OpenAIMessage): Content {
2559
const role = msg.role === 'assistant' ? 'model' : 'user';
2660

61+
if (msg.role === 'tool') {
62+
return {
63+
role: 'user', // Gemini 使用 'user' role 来承载 functionResponse
64+
parts: [
65+
{
66+
functionResponse: {
67+
name: msg.tool_call_id || 'unknown_tool', // 需要一个工具名
68+
response: { content: msg.content },
69+
},
70+
},
71+
],
72+
};
73+
}
74+
2775
if (typeof msg.content === 'string') {
2876
return { role, parts: [{ text: msg.content }] };
2977
}
@@ -41,32 +89,15 @@ export class GeminiApiClient {
4189
const mimeType = mimePart.split(':')[1].split(';')[0];
4290
return { inlineData: { mimeType, data: dataPart } };
4391
}
44-
// Gemini API 可能不支持直接传递 URL,但我们先按协议转换
4592
return { fileData: { mimeType: 'image/jpeg', fileUri: imageUrl } };
4693
}
47-
// 对于不支持的 part 类型,返回一个空文本 part
4894
return { text: '' };
4995
})
50-
.filter((p) => p.text !== '' || p.inlineData || p.fileData); // 过滤掉完全空的 part
96+
.filter((p) => p.text !== '' || p.inlineData || p.fileData);
5197

5298
return { role, parts };
5399
}
54100

55-
// 针对 tool role 的转换
56-
if (msg.role === 'tool' && msg.tool_call_id && msg.content) {
57-
return {
58-
role: 'user', // Gemini 使用 'user' role 来承载 functionResponse
59-
parts: [
60-
{
61-
functionResponse: {
62-
name: msg.tool_call_id, // 这里的映射关系需要确认,通常是工具名
63-
response: { content: msg.content },
64-
},
65-
},
66-
],
67-
};
68-
}
69-
70101
return { role, parts: [{ text: '' }] };
71102
}
72103

@@ -81,32 +112,41 @@ export class GeminiApiClient {
81112
}: {
82113
model: string;
83114
messages: OpenAIMessage[];
84-
tools?: Tool[];
115+
tools?: OpenAIChatCompletionRequest['tools'];
85116
tool_choice?: any;
86117
}) {
87-
// 1. 转换消息格式
88-
const history = messages.map((msg) => this.openAIMessageToGemini(msg));
118+
const history = messages.map(this.openAIMessageToGemini);
89119
const lastMessage = history.pop();
90120
if (!lastMessage) {
91121
throw new Error('No message to send.');
92122
}
93123

94-
// 2. 创建一个一次性的 GeminiChat 实例
95124
const oneShotChat = new GeminiChat(
96125
this.config,
97126
this.contentGenerator,
98-
{}, // generationConfig
99-
history, // 传入历史记录
127+
{},
128+
history,
100129
);
101130

102-
// 3. 构造请求,包含工具定义
131+
const geminiTools = this.convertOpenAIToolsToGemini(tools);
132+
133+
const generationConfig: GenerateContentConfig = {};
134+
if (tool_choice && tool_choice !== 'auto') {
135+
generationConfig.toolConfig = {
136+
functionCallingConfig: {
137+
mode: tool_choice.type === 'function' ? 'ANY' : 'AUTO',
138+
allowedFunctionNames: tool_choice.function
139+
? [tool_choice.function.name]
140+
: undefined,
141+
},
142+
};
143+
}
144+
103145
const geminiStream = await oneShotChat.sendMessageStream({
104146
message: lastMessage.parts || [],
105147
config: {
106-
tools: tools,
107-
toolConfig: tool_choice
108-
? { functionCallingConfig: { mode: tool_choice } }
109-
: undefined,
148+
tools: geminiTools,
149+
...generationConfig,
110150
},
111151
});
112152

0 commit comments

Comments
 (0)