@wener/mcps 1.0.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/LICENSE +21 -0
- package/dist/index.mjs +15 -0
- package/dist/mcps-cli.mjs +174727 -0
- package/lib/chat/agent.js +187 -0
- package/lib/chat/agent.js.map +1 -0
- package/lib/chat/audit.js +238 -0
- package/lib/chat/audit.js.map +1 -0
- package/lib/chat/converters.js +467 -0
- package/lib/chat/converters.js.map +1 -0
- package/lib/chat/handler.js +1068 -0
- package/lib/chat/handler.js.map +1 -0
- package/lib/chat/index.js +12 -0
- package/lib/chat/index.js.map +1 -0
- package/lib/chat/types.js +35 -0
- package/lib/chat/types.js.map +1 -0
- package/lib/contracts/AuditContract.js +85 -0
- package/lib/contracts/AuditContract.js.map +1 -0
- package/lib/contracts/McpsContract.js +113 -0
- package/lib/contracts/McpsContract.js.map +1 -0
- package/lib/contracts/index.js +3 -0
- package/lib/contracts/index.js.map +1 -0
- package/lib/dev.server.js +7 -0
- package/lib/dev.server.js.map +1 -0
- package/lib/entities/ChatRequestEntity.js +318 -0
- package/lib/entities/ChatRequestEntity.js.map +1 -0
- package/lib/entities/McpRequestEntity.js +271 -0
- package/lib/entities/McpRequestEntity.js.map +1 -0
- package/lib/entities/RequestLogEntity.js +177 -0
- package/lib/entities/RequestLogEntity.js.map +1 -0
- package/lib/entities/ResponseEntity.js +150 -0
- package/lib/entities/ResponseEntity.js.map +1 -0
- package/lib/entities/index.js +11 -0
- package/lib/entities/index.js.map +1 -0
- package/lib/entities/types.js +11 -0
- package/lib/entities/types.js.map +1 -0
- package/lib/index.js +3 -0
- package/lib/index.js.map +1 -0
- package/lib/mcps-cli.js +44 -0
- package/lib/mcps-cli.js.map +1 -0
- package/lib/providers/McpServerHandlerDef.js +40 -0
- package/lib/providers/McpServerHandlerDef.js.map +1 -0
- package/lib/providers/findMcpServerDef.js +26 -0
- package/lib/providers/findMcpServerDef.js.map +1 -0
- package/lib/providers/prometheus/def.js +24 -0
- package/lib/providers/prometheus/def.js.map +1 -0
- package/lib/providers/prometheus/index.js +2 -0
- package/lib/providers/prometheus/index.js.map +1 -0
- package/lib/providers/relay/def.js +32 -0
- package/lib/providers/relay/def.js.map +1 -0
- package/lib/providers/relay/index.js +2 -0
- package/lib/providers/relay/index.js.map +1 -0
- package/lib/providers/sql/def.js +31 -0
- package/lib/providers/sql/def.js.map +1 -0
- package/lib/providers/sql/index.js +2 -0
- package/lib/providers/sql/index.js.map +1 -0
- package/lib/providers/tencent-cls/def.js +44 -0
- package/lib/providers/tencent-cls/def.js.map +1 -0
- package/lib/providers/tencent-cls/index.js +2 -0
- package/lib/providers/tencent-cls/index.js.map +1 -0
- package/lib/scripts/bundle.js +90 -0
- package/lib/scripts/bundle.js.map +1 -0
- package/lib/server/api-routes.js +96 -0
- package/lib/server/api-routes.js.map +1 -0
- package/lib/server/audit.js +274 -0
- package/lib/server/audit.js.map +1 -0
- package/lib/server/chat-routes.js +82 -0
- package/lib/server/chat-routes.js.map +1 -0
- package/lib/server/config.js +223 -0
- package/lib/server/config.js.map +1 -0
- package/lib/server/db.js +97 -0
- package/lib/server/db.js.map +1 -0
- package/lib/server/index.js +2 -0
- package/lib/server/index.js.map +1 -0
- package/lib/server/mcp-handler.js +167 -0
- package/lib/server/mcp-handler.js.map +1 -0
- package/lib/server/mcp-routes.js +112 -0
- package/lib/server/mcp-routes.js.map +1 -0
- package/lib/server/mcps-router.js +119 -0
- package/lib/server/mcps-router.js.map +1 -0
- package/lib/server/schema.js +129 -0
- package/lib/server/schema.js.map +1 -0
- package/lib/server/server.js +166 -0
- package/lib/server/server.js.map +1 -0
- package/lib/web/ChatPage.js +827 -0
- package/lib/web/ChatPage.js.map +1 -0
- package/lib/web/McpInspectorPage.js +214 -0
- package/lib/web/McpInspectorPage.js.map +1 -0
- package/lib/web/ServersPage.js +93 -0
- package/lib/web/ServersPage.js.map +1 -0
- package/lib/web/main.js +541 -0
- package/lib/web/main.js.map +1 -0
- package/package.json +83 -0
- package/src/chat/agent.ts +240 -0
- package/src/chat/audit.ts +377 -0
- package/src/chat/converters.test.ts +325 -0
- package/src/chat/converters.ts +459 -0
- package/src/chat/handler.test.ts +137 -0
- package/src/chat/handler.ts +1233 -0
- package/src/chat/index.ts +16 -0
- package/src/chat/types.ts +72 -0
- package/src/contracts/AuditContract.ts +93 -0
- package/src/contracts/McpsContract.ts +141 -0
- package/src/contracts/index.ts +18 -0
- package/src/dev.server.ts +7 -0
- package/src/entities/ChatRequestEntity.ts +157 -0
- package/src/entities/McpRequestEntity.ts +149 -0
- package/src/entities/RequestLogEntity.ts +78 -0
- package/src/entities/ResponseEntity.ts +75 -0
- package/src/entities/index.ts +12 -0
- package/src/entities/types.ts +188 -0
- package/src/index.ts +1 -0
- package/src/mcps-cli.ts +59 -0
- package/src/providers/McpServerHandlerDef.ts +105 -0
- package/src/providers/findMcpServerDef.ts +31 -0
- package/src/providers/prometheus/def.ts +21 -0
- package/src/providers/prometheus/index.ts +1 -0
- package/src/providers/relay/def.ts +31 -0
- package/src/providers/relay/index.ts +1 -0
- package/src/providers/relay/relay.test.ts +47 -0
- package/src/providers/sql/def.ts +33 -0
- package/src/providers/sql/index.ts +1 -0
- package/src/providers/tencent-cls/def.ts +38 -0
- package/src/providers/tencent-cls/index.ts +1 -0
- package/src/scripts/bundle.ts +82 -0
- package/src/server/api-routes.ts +98 -0
- package/src/server/audit.ts +310 -0
- package/src/server/chat-routes.ts +95 -0
- package/src/server/config.test.ts +162 -0
- package/src/server/config.ts +198 -0
- package/src/server/db.ts +115 -0
- package/src/server/index.ts +1 -0
- package/src/server/mcp-handler.ts +209 -0
- package/src/server/mcp-routes.ts +133 -0
- package/src/server/mcps-router.ts +133 -0
- package/src/server/schema.ts +175 -0
- package/src/server/server.ts +163 -0
- package/src/web/ChatPage.tsx +1005 -0
- package/src/web/McpInspectorPage.tsx +254 -0
- package/src/web/ServersPage.tsx +139 -0
- package/src/web/main.tsx +600 -0
- package/src/web/styles.css +15 -0
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Protocol converters between different AI model APIs
|
|
3
|
+
*
|
|
4
|
+
* These converters work with loosely-typed objects to support passthrough of
|
|
5
|
+
* provider-specific fields that aren't in the standard schema.
|
|
6
|
+
*/
|
|
7
|
+
import type { CreateChatCompletionRequest, CreateChatCompletionResponse, Message } from '@wener/ai/openai';
|
|
8
|
+
import type { CreateMessageRequest, CreateMessageResponse } from '@wener/ai/anthropic';
|
|
9
|
+
import type { CreateGenerateContentRequest, CreateGenerateContentResponse } from '@wener/ai/google';
|
|
10
|
+
|
|
11
|
+
// Type aliases for converter functions
|
|
12
|
+
type ChatMessage = Message;
|
|
13
|
+
type AnthropicMessage = CreateMessageRequest['messages'][number];
|
|
14
|
+
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// OpenAI to Anthropic Conversion
|
|
17
|
+
// ============================================================================
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Convert OpenAI messages to Anthropic messages
|
|
21
|
+
*/
|
|
22
|
+
export function openaiToAnthropicMessages(messages: ChatMessage[]): {
|
|
23
|
+
system?: string;
|
|
24
|
+
messages: AnthropicMessage[];
|
|
25
|
+
} {
|
|
26
|
+
let system: string | undefined;
|
|
27
|
+
const anthropicMessages: AnthropicMessage[] = [];
|
|
28
|
+
|
|
29
|
+
for (const msg of messages) {
|
|
30
|
+
if (msg.role === 'system') {
|
|
31
|
+
// Combine system messages
|
|
32
|
+
const contentStr = typeof msg.content === 'string' ? msg.content : '';
|
|
33
|
+
system = system ? `${system}\n\n${contentStr}` : contentStr;
|
|
34
|
+
} else if (msg.role === 'user') {
|
|
35
|
+
const content = typeof msg.content === 'string' ? msg.content : convertContentParts((msg.content as any[]) ?? []);
|
|
36
|
+
anthropicMessages.push({ role: 'user', content } as AnthropicMessage);
|
|
37
|
+
} else if (msg.role === 'assistant') {
|
|
38
|
+
if (msg.tool_calls && msg.tool_calls.length > 0) {
|
|
39
|
+
// Convert tool calls to tool_use blocks
|
|
40
|
+
const content = msg.tool_calls.map((tc: any) => ({
|
|
41
|
+
type: 'tool_use' as const,
|
|
42
|
+
id: tc.id,
|
|
43
|
+
name: tc.function.name,
|
|
44
|
+
input: JSON.parse(tc.function.arguments || '{}'),
|
|
45
|
+
}));
|
|
46
|
+
if (msg.content) {
|
|
47
|
+
const textContent = typeof msg.content === 'string' ? msg.content : '';
|
|
48
|
+
content.unshift({ type: 'text' as any, text: textContent } as any);
|
|
49
|
+
}
|
|
50
|
+
anthropicMessages.push({ role: 'assistant', content } as AnthropicMessage);
|
|
51
|
+
} else {
|
|
52
|
+
const contentStr = typeof msg.content === 'string' ? msg.content : '';
|
|
53
|
+
anthropicMessages.push({ role: 'assistant', content: contentStr || '' } as AnthropicMessage);
|
|
54
|
+
}
|
|
55
|
+
} else if (msg.role === 'tool') {
|
|
56
|
+
// Convert tool message to tool_result
|
|
57
|
+
const contentStr = typeof msg.content === 'string' ? msg.content : '';
|
|
58
|
+
anthropicMessages.push({
|
|
59
|
+
role: 'user',
|
|
60
|
+
content: [
|
|
61
|
+
{
|
|
62
|
+
type: 'tool_result',
|
|
63
|
+
tool_use_id: msg.tool_call_id ?? '',
|
|
64
|
+
content: contentStr,
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
} as AnthropicMessage);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return { system, messages: anthropicMessages };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function convertContentParts(parts: any[]): any[] {
|
|
75
|
+
return parts.map((part) => {
|
|
76
|
+
if (part.type === 'text') {
|
|
77
|
+
return { type: 'text', text: part.text };
|
|
78
|
+
} else if (part.type === 'image_url') {
|
|
79
|
+
const url = part.image_url.url;
|
|
80
|
+
if (url.startsWith('data:')) {
|
|
81
|
+
// Base64 data URL
|
|
82
|
+
const match = url.match(/^data:([^;]+);base64,(.+)$/);
|
|
83
|
+
if (match) {
|
|
84
|
+
return {
|
|
85
|
+
type: 'image',
|
|
86
|
+
source: {
|
|
87
|
+
type: 'base64',
|
|
88
|
+
media_type: match[1],
|
|
89
|
+
data: match[2],
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// URL reference
|
|
95
|
+
return {
|
|
96
|
+
type: 'image',
|
|
97
|
+
source: {
|
|
98
|
+
type: 'url',
|
|
99
|
+
url,
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return part;
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Convert OpenAI request to Anthropic request
|
|
109
|
+
*/
|
|
110
|
+
export function openaiToAnthropicRequest(req: CreateChatCompletionRequest): CreateMessageRequest {
|
|
111
|
+
const { system, messages } = openaiToAnthropicMessages(req.messages);
|
|
112
|
+
|
|
113
|
+
const anthropicReq: CreateMessageRequest = {
|
|
114
|
+
model: req.model,
|
|
115
|
+
messages,
|
|
116
|
+
max_tokens: req.max_tokens || req.max_completion_tokens || 4096,
|
|
117
|
+
stream: req.stream,
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
if (system) {
|
|
121
|
+
anthropicReq.system = system;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (req.temperature !== undefined && req.temperature !== null) {
|
|
125
|
+
anthropicReq.temperature = Math.min(req.temperature, 1); // Anthropic max is 1
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (req.top_p !== undefined) {
|
|
129
|
+
anthropicReq.top_p = req.top_p;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (req.stop) {
|
|
133
|
+
anthropicReq.stop_sequences = Array.isArray(req.stop) ? req.stop : [req.stop];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (req.tools && req.tools.length > 0) {
|
|
137
|
+
anthropicReq.tools = req.tools.map((tool: any) => ({
|
|
138
|
+
name: tool.function.name,
|
|
139
|
+
description: tool.function.description,
|
|
140
|
+
input_schema: {
|
|
141
|
+
type: 'object' as const,
|
|
142
|
+
properties: (tool.function.parameters?.properties || {}) as Record<string, unknown>,
|
|
143
|
+
required: (tool.function.parameters?.required || []) as string[],
|
|
144
|
+
},
|
|
145
|
+
}));
|
|
146
|
+
|
|
147
|
+
if (req.tool_choice) {
|
|
148
|
+
if (req.tool_choice === 'auto') {
|
|
149
|
+
anthropicReq.tool_choice = { type: 'auto' };
|
|
150
|
+
} else if (req.tool_choice === 'required') {
|
|
151
|
+
anthropicReq.tool_choice = { type: 'any' };
|
|
152
|
+
} else if (req.tool_choice === 'none') {
|
|
153
|
+
// Anthropic doesn't have 'none', just don't include tools
|
|
154
|
+
delete anthropicReq.tools;
|
|
155
|
+
} else if (typeof req.tool_choice === 'object') {
|
|
156
|
+
anthropicReq.tool_choice = {
|
|
157
|
+
type: 'tool',
|
|
158
|
+
name: req.tool_choice.function.name,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return anthropicReq;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Convert Anthropic response to OpenAI response
|
|
169
|
+
*/
|
|
170
|
+
export function anthropicToOpenaiResponse(res: CreateMessageResponse, model: string): CreateChatCompletionResponse {
|
|
171
|
+
const toolCalls: any[] = [];
|
|
172
|
+
let textContent = '';
|
|
173
|
+
|
|
174
|
+
for (const block of res.content) {
|
|
175
|
+
if (block.type === 'text') {
|
|
176
|
+
textContent += block.text;
|
|
177
|
+
} else if (block.type === 'tool_use') {
|
|
178
|
+
toolCalls.push({
|
|
179
|
+
id: block.id,
|
|
180
|
+
type: 'function',
|
|
181
|
+
function: {
|
|
182
|
+
name: block.name,
|
|
183
|
+
arguments: JSON.stringify(block.input),
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const finishReason = (() => {
|
|
190
|
+
switch (res.stop_reason) {
|
|
191
|
+
case 'end_turn':
|
|
192
|
+
return 'stop';
|
|
193
|
+
case 'max_tokens':
|
|
194
|
+
return 'length';
|
|
195
|
+
case 'tool_use':
|
|
196
|
+
return 'tool_calls';
|
|
197
|
+
case 'stop_sequence':
|
|
198
|
+
return 'stop';
|
|
199
|
+
default:
|
|
200
|
+
return 'stop';
|
|
201
|
+
}
|
|
202
|
+
})();
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
id: res.id,
|
|
206
|
+
object: 'chat.completion',
|
|
207
|
+
created: Math.floor(Date.now() / 1000),
|
|
208
|
+
model: model,
|
|
209
|
+
choices: [
|
|
210
|
+
{
|
|
211
|
+
index: 0,
|
|
212
|
+
message: {
|
|
213
|
+
role: 'assistant',
|
|
214
|
+
content: textContent || null,
|
|
215
|
+
...(toolCalls.length > 0 && { tool_calls: toolCalls }),
|
|
216
|
+
},
|
|
217
|
+
finish_reason: finishReason as any,
|
|
218
|
+
},
|
|
219
|
+
],
|
|
220
|
+
usage: {
|
|
221
|
+
prompt_tokens: res.usage.input_tokens,
|
|
222
|
+
completion_tokens: res.usage.output_tokens,
|
|
223
|
+
total_tokens: res.usage.input_tokens + res.usage.output_tokens,
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ============================================================================
|
|
229
|
+
// OpenAI to Gemini Conversion
|
|
230
|
+
// ============================================================================
|
|
231
|
+
|
|
232
|
+
// Gemini content type for converter
|
|
233
|
+
type GeminiContentPart = {
|
|
234
|
+
text?: string;
|
|
235
|
+
inlineData?: { mimeType: string; data: string };
|
|
236
|
+
functionCall?: unknown;
|
|
237
|
+
functionResponse?: unknown;
|
|
238
|
+
};
|
|
239
|
+
type GeminiContent = { role: string; parts: GeminiContentPart[] };
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Convert OpenAI messages to Gemini contents
|
|
243
|
+
*/
|
|
244
|
+
export function openaiToGeminiContents(messages: ChatMessage[]): {
|
|
245
|
+
systemInstruction?: GeminiContent;
|
|
246
|
+
contents: GeminiContent[];
|
|
247
|
+
} {
|
|
248
|
+
let systemInstruction: GeminiContent | undefined;
|
|
249
|
+
const contents: GeminiContent[] = [];
|
|
250
|
+
|
|
251
|
+
for (const msg of messages) {
|
|
252
|
+
if (msg.role === 'system' || msg.role === 'developer') {
|
|
253
|
+
// Gemini uses systemInstruction
|
|
254
|
+
const text = typeof msg.content === 'string' ? msg.content : '';
|
|
255
|
+
if (systemInstruction) {
|
|
256
|
+
// Append to existing
|
|
257
|
+
systemInstruction.parts.push({ text });
|
|
258
|
+
} else {
|
|
259
|
+
systemInstruction = {
|
|
260
|
+
role: 'user' as const, // Gemini system instruction doesn't have role, but we use 'user'
|
|
261
|
+
parts: [{ text }],
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
} else if (msg.role === 'user') {
|
|
265
|
+
const contentArr = Array.isArray(msg.content) ? msg.content : null;
|
|
266
|
+
const parts =
|
|
267
|
+
typeof msg.content === 'string'
|
|
268
|
+
? [{ text: msg.content }]
|
|
269
|
+
: (contentArr ?? []).map((c: any) => {
|
|
270
|
+
if (c.type === 'text') {
|
|
271
|
+
return { text: c.text };
|
|
272
|
+
} else if (c.type === 'image_url') {
|
|
273
|
+
const url = c.image_url.url;
|
|
274
|
+
if (url.startsWith('data:')) {
|
|
275
|
+
const match = url.match(/^data:([^;]+);base64,(.+)$/);
|
|
276
|
+
if (match) {
|
|
277
|
+
return {
|
|
278
|
+
inlineData: {
|
|
279
|
+
mimeType: match[1],
|
|
280
|
+
data: match[2],
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return { fileData: { fileUri: url } };
|
|
286
|
+
}
|
|
287
|
+
return { text: '' };
|
|
288
|
+
});
|
|
289
|
+
contents.push({ role: 'user' as const, parts });
|
|
290
|
+
} else if (msg.role === 'assistant') {
|
|
291
|
+
if (msg.tool_calls && msg.tool_calls.length > 0) {
|
|
292
|
+
const parts = msg.tool_calls.map((tc: any) => ({
|
|
293
|
+
functionCall: {
|
|
294
|
+
name: tc.function.name,
|
|
295
|
+
args: JSON.parse(tc.function.arguments || '{}'),
|
|
296
|
+
},
|
|
297
|
+
}));
|
|
298
|
+
if (msg.content) {
|
|
299
|
+
const textContent = typeof msg.content === 'string' ? msg.content : '';
|
|
300
|
+
parts.unshift({ text: textContent } as any);
|
|
301
|
+
}
|
|
302
|
+
contents.push({ role: 'model' as const, parts });
|
|
303
|
+
} else {
|
|
304
|
+
const textContent = typeof msg.content === 'string' ? msg.content : '';
|
|
305
|
+
contents.push({ role: 'model' as const, parts: [{ text: textContent || '' }] });
|
|
306
|
+
}
|
|
307
|
+
} else if (msg.role === 'tool') {
|
|
308
|
+
// Convert to function response
|
|
309
|
+
const contentStr = typeof msg.content === 'string' ? msg.content : '';
|
|
310
|
+
contents.push({
|
|
311
|
+
role: 'user' as const,
|
|
312
|
+
parts: [
|
|
313
|
+
{
|
|
314
|
+
functionResponse: {
|
|
315
|
+
name: 'function', // We don't have the function name in tool message
|
|
316
|
+
response: { result: contentStr },
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
],
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return { systemInstruction, contents };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Convert OpenAI request to Gemini request
|
|
329
|
+
*/
|
|
330
|
+
export function openaiToGeminiRequest(req: CreateChatCompletionRequest): CreateGenerateContentRequest {
|
|
331
|
+
const { systemInstruction, contents } = openaiToGeminiContents(req.messages);
|
|
332
|
+
|
|
333
|
+
const geminiReq: CreateGenerateContentRequest = {
|
|
334
|
+
contents: contents as CreateGenerateContentRequest['contents'],
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
if (systemInstruction) {
|
|
338
|
+
geminiReq.systemInstruction = systemInstruction as CreateGenerateContentRequest['systemInstruction'];
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const generationConfig: any = {};
|
|
342
|
+
|
|
343
|
+
if (req.temperature !== undefined) {
|
|
344
|
+
generationConfig.temperature = req.temperature;
|
|
345
|
+
}
|
|
346
|
+
if (req.top_p !== undefined) {
|
|
347
|
+
generationConfig.topP = req.top_p;
|
|
348
|
+
}
|
|
349
|
+
if (req.max_tokens || req.max_completion_tokens) {
|
|
350
|
+
generationConfig.maxOutputTokens = req.max_tokens || req.max_completion_tokens;
|
|
351
|
+
}
|
|
352
|
+
if (req.stop) {
|
|
353
|
+
generationConfig.stopSequences = Array.isArray(req.stop) ? req.stop : [req.stop];
|
|
354
|
+
}
|
|
355
|
+
if (req.n) {
|
|
356
|
+
generationConfig.candidateCount = req.n;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (Object.keys(generationConfig).length > 0) {
|
|
360
|
+
geminiReq.generationConfig = generationConfig;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (req.tools && req.tools.length > 0) {
|
|
364
|
+
geminiReq.tools = [
|
|
365
|
+
{
|
|
366
|
+
functionDeclarations: req.tools.map((tool: any) => ({
|
|
367
|
+
name: tool.function.name,
|
|
368
|
+
description: tool.function.description,
|
|
369
|
+
parameters: tool.function.parameters,
|
|
370
|
+
})),
|
|
371
|
+
},
|
|
372
|
+
];
|
|
373
|
+
|
|
374
|
+
if (req.tool_choice) {
|
|
375
|
+
if (req.tool_choice === 'auto') {
|
|
376
|
+
geminiReq.toolConfig = { functionCallingConfig: { mode: 'AUTO' } };
|
|
377
|
+
} else if (req.tool_choice === 'required') {
|
|
378
|
+
geminiReq.toolConfig = { functionCallingConfig: { mode: 'ANY' } };
|
|
379
|
+
} else if (req.tool_choice === 'none') {
|
|
380
|
+
geminiReq.toolConfig = { functionCallingConfig: { mode: 'NONE' } };
|
|
381
|
+
} else if (typeof req.tool_choice === 'object') {
|
|
382
|
+
geminiReq.toolConfig = {
|
|
383
|
+
functionCallingConfig: {
|
|
384
|
+
mode: 'ANY',
|
|
385
|
+
allowedFunctionNames: [req.tool_choice.function.name],
|
|
386
|
+
},
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return geminiReq;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Convert Gemini response to OpenAI response
|
|
397
|
+
*/
|
|
398
|
+
export function geminiToOpenaiResponse(
|
|
399
|
+
res: CreateGenerateContentResponse,
|
|
400
|
+
model: string,
|
|
401
|
+
): CreateChatCompletionResponse {
|
|
402
|
+
const choices = (res.candidates || []).map((candidate: any, index: number) => {
|
|
403
|
+
const toolCalls: any[] = [];
|
|
404
|
+
let textContent = '';
|
|
405
|
+
|
|
406
|
+
for (const part of candidate.content?.parts || []) {
|
|
407
|
+
if ('text' in part) {
|
|
408
|
+
textContent += part.text;
|
|
409
|
+
} else if ('functionCall' in part) {
|
|
410
|
+
toolCalls.push({
|
|
411
|
+
id: `call_${Date.now()}_${index}`,
|
|
412
|
+
type: 'function',
|
|
413
|
+
function: {
|
|
414
|
+
name: part.functionCall.name,
|
|
415
|
+
arguments: JSON.stringify(part.functionCall.args),
|
|
416
|
+
},
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const finishReason = (() => {
|
|
422
|
+
switch (candidate.finishReason) {
|
|
423
|
+
case 'STOP':
|
|
424
|
+
return 'stop';
|
|
425
|
+
case 'MAX_TOKENS':
|
|
426
|
+
return 'length';
|
|
427
|
+
case 'SAFETY':
|
|
428
|
+
return 'content_filter';
|
|
429
|
+
default:
|
|
430
|
+
return 'stop';
|
|
431
|
+
}
|
|
432
|
+
})();
|
|
433
|
+
|
|
434
|
+
return {
|
|
435
|
+
index: candidate.index ?? index,
|
|
436
|
+
message: {
|
|
437
|
+
role: 'assistant' as const,
|
|
438
|
+
content: textContent || null,
|
|
439
|
+
...(toolCalls.length > 0 && { tool_calls: toolCalls }),
|
|
440
|
+
},
|
|
441
|
+
finish_reason: finishReason as any,
|
|
442
|
+
};
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
return {
|
|
446
|
+
id: `gemini-${Date.now()}`,
|
|
447
|
+
object: 'chat.completion',
|
|
448
|
+
created: Math.floor(Date.now() / 1000),
|
|
449
|
+
model,
|
|
450
|
+
choices,
|
|
451
|
+
usage: res.usageMetadata
|
|
452
|
+
? {
|
|
453
|
+
prompt_tokens: res.usageMetadata.promptTokenCount || 0,
|
|
454
|
+
completion_tokens: res.usageMetadata.candidatesTokenCount || 0,
|
|
455
|
+
total_tokens: res.usageMetadata.totalTokenCount || 0,
|
|
456
|
+
}
|
|
457
|
+
: undefined,
|
|
458
|
+
};
|
|
459
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import type { ChatConfig } from '../server/schema';
|
|
3
|
+
import { createChatHandler } from './handler';
|
|
4
|
+
|
|
5
|
+
describe('Chat Handler', () => {
|
|
6
|
+
const config: ChatConfig = {
|
|
7
|
+
models: [
|
|
8
|
+
{
|
|
9
|
+
name: 'test-model',
|
|
10
|
+
baseUrl: 'http://127.0.0.1:31235',
|
|
11
|
+
adapter: 'openai',
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
name: 'qwen*',
|
|
15
|
+
baseUrl: 'http://127.0.0.1:31235',
|
|
16
|
+
adapter: 'openai',
|
|
17
|
+
},
|
|
18
|
+
],
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const app = createChatHandler({ config });
|
|
22
|
+
|
|
23
|
+
describe('models endpoint', () => {
|
|
24
|
+
it('should list configured models', async () => {
|
|
25
|
+
const res = await app.request('/v1/models');
|
|
26
|
+
expect(res.status).toBe(200);
|
|
27
|
+
|
|
28
|
+
const data = await res.json();
|
|
29
|
+
expect(data.object).toBe('list');
|
|
30
|
+
expect(data.data).toHaveLength(2);
|
|
31
|
+
expect(data.data.map((m: any) => m.id)).toContain('test-model');
|
|
32
|
+
expect(data.data.map((m: any) => m.id)).toContain('qwen*');
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('chat completions validation', () => {
|
|
37
|
+
it('should reject requests without model', async () => {
|
|
38
|
+
const res = await app.request('/v1/chat/completions', {
|
|
39
|
+
method: 'POST',
|
|
40
|
+
headers: { 'Content-Type': 'application/json' },
|
|
41
|
+
body: JSON.stringify({
|
|
42
|
+
messages: [{ role: 'user', content: 'Hello' }],
|
|
43
|
+
}),
|
|
44
|
+
});
|
|
45
|
+
expect(res.status).toBe(400);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should reject requests without messages', async () => {
|
|
49
|
+
const res = await app.request('/v1/chat/completions', {
|
|
50
|
+
method: 'POST',
|
|
51
|
+
headers: { 'Content-Type': 'application/json' },
|
|
52
|
+
body: JSON.stringify({
|
|
53
|
+
model: 'test-model',
|
|
54
|
+
}),
|
|
55
|
+
});
|
|
56
|
+
expect(res.status).toBe(400);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should reject unconfigured model', async () => {
|
|
60
|
+
const res = await app.request('/v1/chat/completions', {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
headers: { 'Content-Type': 'application/json' },
|
|
63
|
+
body: JSON.stringify({
|
|
64
|
+
model: 'unknown-model',
|
|
65
|
+
messages: [{ role: 'user', content: 'Hello' }],
|
|
66
|
+
}),
|
|
67
|
+
});
|
|
68
|
+
expect(res.status).toBe(404);
|
|
69
|
+
|
|
70
|
+
const data = await res.json();
|
|
71
|
+
expect(data.error.code).toBe('model_not_found');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should match wildcard model patterns', async () => {
|
|
75
|
+
// This will fail at upstream request, but it should pass model resolution
|
|
76
|
+
const res = await app.request('/v1/chat/completions', {
|
|
77
|
+
method: 'POST',
|
|
78
|
+
headers: { 'Content-Type': 'application/json' },
|
|
79
|
+
body: JSON.stringify({
|
|
80
|
+
model: 'qwen3-vl-8b-instruct',
|
|
81
|
+
messages: [{ role: 'user', content: 'Hello' }],
|
|
82
|
+
}),
|
|
83
|
+
});
|
|
84
|
+
// Should not be 404 (model found), but might be 500 (upstream error in test env)
|
|
85
|
+
expect(res.status).not.toBe(404);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('anthropic messages validation', () => {
|
|
90
|
+
it('should reject requests without required fields', async () => {
|
|
91
|
+
const res = await app.request('/v1/messages', {
|
|
92
|
+
method: 'POST',
|
|
93
|
+
headers: { 'Content-Type': 'application/json' },
|
|
94
|
+
body: JSON.stringify({
|
|
95
|
+
model: 'test-model',
|
|
96
|
+
messages: [{ role: 'user', content: 'Hello' }],
|
|
97
|
+
// missing max_tokens
|
|
98
|
+
}),
|
|
99
|
+
});
|
|
100
|
+
expect(res.status).toBe(400);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Integration tests that require network access
|
|
106
|
+
describe.skip('Chat Handler Integration', () => {
|
|
107
|
+
const config: ChatConfig = {
|
|
108
|
+
models: [
|
|
109
|
+
{
|
|
110
|
+
name: 'qwen*',
|
|
111
|
+
baseUrl: 'http://127.0.0.1:31235',
|
|
112
|
+
adapter: 'openai',
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const app = createChatHandler({ config });
|
|
118
|
+
|
|
119
|
+
it('should complete chat with local model', async () => {
|
|
120
|
+
const res = await app.request('/v1/chat/completions', {
|
|
121
|
+
method: 'POST',
|
|
122
|
+
headers: { 'Content-Type': 'application/json' },
|
|
123
|
+
body: JSON.stringify({
|
|
124
|
+
model: 'qwen3-vl-8b-instruct',
|
|
125
|
+
messages: [{ role: 'user', content: 'Say hello in one word' }],
|
|
126
|
+
max_tokens: 50,
|
|
127
|
+
}),
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
expect(res.status).toBe(200);
|
|
131
|
+
const data = await res.json();
|
|
132
|
+
expect(data.object).toBe('chat.completion');
|
|
133
|
+
expect(data.choices).toHaveLength(1);
|
|
134
|
+
expect(data.choices[0].message.role).toBe('assistant');
|
|
135
|
+
expect(data.choices[0].message.content).toBeTruthy();
|
|
136
|
+
}, 30000);
|
|
137
|
+
});
|