Skip to content

Commit 9005d6d

Browse files
feat: 集成 OpenAI 兼容的聊天补全端点
Co-authored-by: aider (vertex_ai/gemini-2.5-pro) <aider@aider.chat>
1 parent da3716d commit 9005d6d

File tree

4 files changed

+254
-14
lines changed

4 files changed

+254
-14
lines changed

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

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import express, { Request, Response, NextFunction } from 'express';
1+
import express, { Request, Response, NextFunction, Application } from 'express';
22
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
33
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
44
import { z } from 'zod';
@@ -53,12 +53,9 @@ export class GcliMcpBridge {
5353
);
5454
}
5555

56-
public async start(port: number) {
56+
public async start(app: Application) {
5757
await this.registerAllGcliTools();
5858

59-
const app = express();
60-
app.use(express.json());
61-
6259
// NEW: 使用日志中间件
6360
app.use(requestLogger);
6461

@@ -124,12 +121,6 @@ export class GcliMcpBridge {
124121
}
125122
}
126123
});
127-
128-
app.listen(port, () => {
129-
console.log(
130-
`${LOG_PREFIX} 🎧 MCP transport listening on http://localhost:${port}/mcp`,
131-
);
132-
});
133124
}
134125

135126
private async registerAllGcliTools() {
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { Router, Request, Response } from 'express';
2+
import { type Config, GeminiChat } from '@google/gemini-cli-core';
3+
import { createOpenAIStreamTransformer } from './stream-transformer.js';
4+
import { type Content } from '@google/genai';
5+
import { WritableStream } from 'node:stream/web';
6+
import { randomUUID } from 'node:crypto';
7+
8+
// 定义 OpenAI 请求体的接口
9+
interface OpenAIChatCompletionRequest {
10+
model: string;
11+
messages: Array<{ role: string; content: string }>;
12+
stream?: boolean;
13+
}
14+
15+
export function createOpenAIRouter(config: Config): Router {
16+
const router = Router();
17+
// 注意:基于 bridge.ts 的现有代码,我们假设 getGeminiClient 和 getContentGenerator 方法存在。
18+
const contentGenerator = config.getGeminiClient().getContentGenerator();
19+
20+
// OpenAI chat completions 端点
21+
router.post(
22+
'/chat/completions',
23+
async (req: Request, res: Response) => {
24+
const body = req.body as OpenAIChatCompletionRequest;
25+
26+
// 确保 stream 默认为 true,除非显式设置为 false
27+
const stream = body.stream !== false;
28+
29+
if (!body.messages || body.messages.length === 0) {
30+
return res.status(400).json({ error: 'messages is required' });
31+
}
32+
33+
// 将 OpenAI 格式的 messages 转换为 Gemini 格式
34+
// 注意:这里简化了处理,实际可能需要处理 system prompt 等
35+
const history: Content[] = body.messages.map(msg => ({
36+
role: msg.role === 'assistant' ? 'model' : 'user',
37+
parts: [{ text: msg.content }],
38+
}));
39+
40+
const lastMessage = history.pop();
41+
if (!lastMessage) {
42+
return res.status(400).json({ error: 'No message to send.' });
43+
}
44+
45+
try {
46+
const oneShotChat = new GeminiChat(
47+
config,
48+
contentGenerator,
49+
{}, // generationConfig
50+
history,
51+
);
52+
53+
if (stream) {
54+
// --- 流式响应 ---
55+
res.setHeader('Content-Type', 'text/event-stream');
56+
res.setHeader('Cache-Control', 'no-cache');
57+
res.setHeader('Connection', 'keep-alive');
58+
res.flushHeaders(); // 立即发送头信息
59+
60+
const geminiStream = await oneShotChat.sendMessageStream({
61+
message: lastMessage.parts,
62+
});
63+
const openAIStream = createOpenAIStreamTransformer(body.model);
64+
65+
const writer = new WritableStream({
66+
write(chunk) {
67+
res.write(chunk);
68+
},
69+
});
70+
71+
const readableStream = new ReadableStream({
72+
async start(controller) {
73+
for await (const value of geminiStream) {
74+
controller.enqueue(value);
75+
}
76+
controller.close();
77+
},
78+
});
79+
80+
await readableStream.pipeThrough(openAIStream).pipeTo(writer);
81+
82+
res.end();
83+
} else {
84+
// --- 非流式响应(为了完整性) ---
85+
const result = await oneShotChat.sendMessage({
86+
message: lastMessage.parts,
87+
});
88+
const responseText =
89+
result.candidates?.[0]?.content?.parts?.[0]?.text || '';
90+
91+
res.json({
92+
id: `chatcmpl-${randomUUID()}`,
93+
object: 'chat.completion',
94+
created: Math.floor(Date.now() / 1000),
95+
model: body.model,
96+
choices: [
97+
{
98+
index: 0,
99+
message: {
100+
role: 'assistant',
101+
content: responseText,
102+
},
103+
finish_reason: 'stop',
104+
},
105+
],
106+
});
107+
}
108+
} catch (error) {
109+
console.error('[OpenAI Bridge] Error:', error);
110+
const errorMessage =
111+
error instanceof Error ? error.message : 'An unknown error occurred';
112+
if (!res.headersSent) {
113+
res.status(500).json({ error: errorMessage });
114+
} else {
115+
// 如果头已发送,只能尝试写入错误信息并结束流
116+
res.write(`data: ${JSON.stringify({ error: errorMessage })}\n\n`);
117+
res.end();
118+
}
119+
}
120+
},
121+
);
122+
123+
// 可以添加 /v1/models 端点
124+
router.get('/models', (req, res) => {
125+
// 这里可以返回一个固定的模型列表,或者从 config 中获取
126+
res.json({
127+
object: 'list',
128+
data: [
129+
{ id: 'gemini-1.5-pro-latest', object: 'model', owned_by: 'google' },
130+
{ id: 'gemini-1.5-flash-latest', object: 'model', owned_by: 'google' },
131+
{ id: 'gemini-pro', object: 'model', owned_by: 'google' },
132+
],
133+
});
134+
});
135+
136+
return router;
137+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { GenerateContentResponse } from '@google/genai';
2+
3+
// 定义 OpenAI 流式响应的块结构
4+
interface OpenAIDelta {
5+
role?: 'user' | 'assistant' | 'system';
6+
content?: string;
7+
}
8+
9+
interface OpenAIChoice {
10+
index: number;
11+
delta: OpenAIDelta;
12+
finish_reason: string | null;
13+
}
14+
15+
interface OpenAIChunk {
16+
id: string;
17+
object: 'chat.completion.chunk';
18+
created: number;
19+
model: string;
20+
choices: OpenAIChoice[];
21+
}
22+
23+
/**
24+
* 创建一个 TransformStream,将 Gemini 的流式响应块转换为 OpenAI 兼容的 SSE 事件。
25+
* @param model - 正在使用的模型名称,用于填充 OpenAI 响应。
26+
* @returns 一个 TransformStream 实例。
27+
*/
28+
export function createOpenAIStreamTransformer(
29+
model: string,
30+
): TransformStream<GenerateContentResponse, Uint8Array> {
31+
const chatID = `chatcmpl-${crypto.randomUUID()}`;
32+
const creationTime = Math.floor(Date.now() / 1000);
33+
const encoder = new TextEncoder();
34+
let isFirstChunk = true;
35+
36+
return new TransformStream({
37+
transform(chunk, controller) {
38+
const text = chunk.candidates?.[0]?.content?.parts?.[0]?.text;
39+
if (!text) {
40+
return;
41+
}
42+
43+
const delta: OpenAIDelta = { content: text };
44+
if (isFirstChunk) {
45+
delta.role = 'assistant';
46+
isFirstChunk = false;
47+
}
48+
49+
const openAIChunk: OpenAIChunk = {
50+
id: chatID,
51+
object: 'chat.completion.chunk',
52+
created: creationTime,
53+
model: model,
54+
choices: [
55+
{
56+
index: 0,
57+
delta: delta,
58+
finish_reason: null,
59+
},
60+
],
61+
};
62+
63+
// 按照 SSE 格式编码
64+
const sseString = `data: ${JSON.stringify(openAIChunk)}\n\n`;
65+
controller.enqueue(encoder.encode(sseString));
66+
},
67+
flush(controller) {
68+
// 流结束时,发送 [DONE] 消息
69+
const doneString = `data: [DONE]\n\n`;
70+
controller.enqueue(encoder.encode(doneString));
71+
},
72+
});
73+
}

packages/mcp-server/src/index.ts

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,18 @@ import {
1818
loadEnvironment,
1919
loadSandboxConfig,
2020
} from '@google/gemini-cli/public-api';
21+
import {
22+
loadSettings,
23+
type Settings,
24+
loadExtensions,
25+
type Extension,
26+
getCliVersion,
27+
loadEnvironment,
28+
loadSandboxConfig,
29+
} from '@google/gemini-cli/public-api';
2130
import { GcliMcpBridge } from './bridge/bridge.js';
31+
import { createOpenAIRouter } from './bridge/openai.js';
32+
import express from 'express';
2233

2334
// Simple console logger for now
2435
const logger = {
@@ -131,11 +142,39 @@ async function startMcpServer() {
131142
selectedAuthType = selectedAuthType || AuthType.USE_GEMINI;
132143
await config.refreshAuth(selectedAuthType);
133144

134-
// 4. 初始化并启动 MCP 桥接服务
145+
// Initialize Auth - this is critical to initialize the tool registry and gemini client
146+
let selectedAuthType = settings.merged.selectedAuthType;
147+
if (!selectedAuthType && !process.env.GEMINI_API_KEY) {
148+
console.error(
149+
'Auth missing: Please set `selectedAuthType` in .gemini/settings.json or set the GEMINI_API_KEY environment variable.',
150+
);
151+
process.exit(1);
152+
}
153+
selectedAuthType = selectedAuthType || AuthType.USE_GEMINI;
154+
await config.refreshAuth(selectedAuthType);
155+
156+
// 4. 初始化并启动 MCP 桥接服务 和 OpenAI 服务
135157
const mcpBridge = new GcliMcpBridge(config, cliVersion);
136-
await mcpBridge.start(port);
137158

138-
console.log(`Gemini CLI MCP Bridge is running on port ${port}`);
159+
const app = express();
160+
app.use(express.json());
161+
162+
// 启动 MCP 服务 (这是 GcliMcpBridge 的一部分,我们需要把它集成到主 app 中)
163+
await mcpBridge.start(app); // 修改 start 方法以接收 express app 实例
164+
165+
// 启动 OpenAI 兼容端点
166+
const openAIRouter = createOpenAIRouter(config);
167+
app.use('/v1', openAIRouter);
168+
169+
app.listen(port, () => {
170+
console.log(
171+
`🚀 Gemini CLI MCP Server and OpenAI Bridge are running on port ${port}`,
172+
);
173+
console.log(` - MCP transport listening on http://localhost:${port}/mcp`);
174+
console.log(
175+
` - OpenAI-compatible endpoints available at http://localhost:${port}/v1`,
176+
);
177+
});
139178
}
140179

141180
startMcpServer().catch(error => {

0 commit comments

Comments
 (0)