@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.
Files changed (141) hide show
  1. package/LICENSE +21 -0
  2. package/dist/index.mjs +15 -0
  3. package/dist/mcps-cli.mjs +174727 -0
  4. package/lib/chat/agent.js +187 -0
  5. package/lib/chat/agent.js.map +1 -0
  6. package/lib/chat/audit.js +238 -0
  7. package/lib/chat/audit.js.map +1 -0
  8. package/lib/chat/converters.js +467 -0
  9. package/lib/chat/converters.js.map +1 -0
  10. package/lib/chat/handler.js +1068 -0
  11. package/lib/chat/handler.js.map +1 -0
  12. package/lib/chat/index.js +12 -0
  13. package/lib/chat/index.js.map +1 -0
  14. package/lib/chat/types.js +35 -0
  15. package/lib/chat/types.js.map +1 -0
  16. package/lib/contracts/AuditContract.js +85 -0
  17. package/lib/contracts/AuditContract.js.map +1 -0
  18. package/lib/contracts/McpsContract.js +113 -0
  19. package/lib/contracts/McpsContract.js.map +1 -0
  20. package/lib/contracts/index.js +3 -0
  21. package/lib/contracts/index.js.map +1 -0
  22. package/lib/dev.server.js +7 -0
  23. package/lib/dev.server.js.map +1 -0
  24. package/lib/entities/ChatRequestEntity.js +318 -0
  25. package/lib/entities/ChatRequestEntity.js.map +1 -0
  26. package/lib/entities/McpRequestEntity.js +271 -0
  27. package/lib/entities/McpRequestEntity.js.map +1 -0
  28. package/lib/entities/RequestLogEntity.js +177 -0
  29. package/lib/entities/RequestLogEntity.js.map +1 -0
  30. package/lib/entities/ResponseEntity.js +150 -0
  31. package/lib/entities/ResponseEntity.js.map +1 -0
  32. package/lib/entities/index.js +11 -0
  33. package/lib/entities/index.js.map +1 -0
  34. package/lib/entities/types.js +11 -0
  35. package/lib/entities/types.js.map +1 -0
  36. package/lib/index.js +3 -0
  37. package/lib/index.js.map +1 -0
  38. package/lib/mcps-cli.js +44 -0
  39. package/lib/mcps-cli.js.map +1 -0
  40. package/lib/providers/McpServerHandlerDef.js +40 -0
  41. package/lib/providers/McpServerHandlerDef.js.map +1 -0
  42. package/lib/providers/findMcpServerDef.js +26 -0
  43. package/lib/providers/findMcpServerDef.js.map +1 -0
  44. package/lib/providers/prometheus/def.js +24 -0
  45. package/lib/providers/prometheus/def.js.map +1 -0
  46. package/lib/providers/prometheus/index.js +2 -0
  47. package/lib/providers/prometheus/index.js.map +1 -0
  48. package/lib/providers/relay/def.js +32 -0
  49. package/lib/providers/relay/def.js.map +1 -0
  50. package/lib/providers/relay/index.js +2 -0
  51. package/lib/providers/relay/index.js.map +1 -0
  52. package/lib/providers/sql/def.js +31 -0
  53. package/lib/providers/sql/def.js.map +1 -0
  54. package/lib/providers/sql/index.js +2 -0
  55. package/lib/providers/sql/index.js.map +1 -0
  56. package/lib/providers/tencent-cls/def.js +44 -0
  57. package/lib/providers/tencent-cls/def.js.map +1 -0
  58. package/lib/providers/tencent-cls/index.js +2 -0
  59. package/lib/providers/tencent-cls/index.js.map +1 -0
  60. package/lib/scripts/bundle.js +90 -0
  61. package/lib/scripts/bundle.js.map +1 -0
  62. package/lib/server/api-routes.js +96 -0
  63. package/lib/server/api-routes.js.map +1 -0
  64. package/lib/server/audit.js +274 -0
  65. package/lib/server/audit.js.map +1 -0
  66. package/lib/server/chat-routes.js +82 -0
  67. package/lib/server/chat-routes.js.map +1 -0
  68. package/lib/server/config.js +223 -0
  69. package/lib/server/config.js.map +1 -0
  70. package/lib/server/db.js +97 -0
  71. package/lib/server/db.js.map +1 -0
  72. package/lib/server/index.js +2 -0
  73. package/lib/server/index.js.map +1 -0
  74. package/lib/server/mcp-handler.js +167 -0
  75. package/lib/server/mcp-handler.js.map +1 -0
  76. package/lib/server/mcp-routes.js +112 -0
  77. package/lib/server/mcp-routes.js.map +1 -0
  78. package/lib/server/mcps-router.js +119 -0
  79. package/lib/server/mcps-router.js.map +1 -0
  80. package/lib/server/schema.js +129 -0
  81. package/lib/server/schema.js.map +1 -0
  82. package/lib/server/server.js +166 -0
  83. package/lib/server/server.js.map +1 -0
  84. package/lib/web/ChatPage.js +827 -0
  85. package/lib/web/ChatPage.js.map +1 -0
  86. package/lib/web/McpInspectorPage.js +214 -0
  87. package/lib/web/McpInspectorPage.js.map +1 -0
  88. package/lib/web/ServersPage.js +93 -0
  89. package/lib/web/ServersPage.js.map +1 -0
  90. package/lib/web/main.js +541 -0
  91. package/lib/web/main.js.map +1 -0
  92. package/package.json +83 -0
  93. package/src/chat/agent.ts +240 -0
  94. package/src/chat/audit.ts +377 -0
  95. package/src/chat/converters.test.ts +325 -0
  96. package/src/chat/converters.ts +459 -0
  97. package/src/chat/handler.test.ts +137 -0
  98. package/src/chat/handler.ts +1233 -0
  99. package/src/chat/index.ts +16 -0
  100. package/src/chat/types.ts +72 -0
  101. package/src/contracts/AuditContract.ts +93 -0
  102. package/src/contracts/McpsContract.ts +141 -0
  103. package/src/contracts/index.ts +18 -0
  104. package/src/dev.server.ts +7 -0
  105. package/src/entities/ChatRequestEntity.ts +157 -0
  106. package/src/entities/McpRequestEntity.ts +149 -0
  107. package/src/entities/RequestLogEntity.ts +78 -0
  108. package/src/entities/ResponseEntity.ts +75 -0
  109. package/src/entities/index.ts +12 -0
  110. package/src/entities/types.ts +188 -0
  111. package/src/index.ts +1 -0
  112. package/src/mcps-cli.ts +59 -0
  113. package/src/providers/McpServerHandlerDef.ts +105 -0
  114. package/src/providers/findMcpServerDef.ts +31 -0
  115. package/src/providers/prometheus/def.ts +21 -0
  116. package/src/providers/prometheus/index.ts +1 -0
  117. package/src/providers/relay/def.ts +31 -0
  118. package/src/providers/relay/index.ts +1 -0
  119. package/src/providers/relay/relay.test.ts +47 -0
  120. package/src/providers/sql/def.ts +33 -0
  121. package/src/providers/sql/index.ts +1 -0
  122. package/src/providers/tencent-cls/def.ts +38 -0
  123. package/src/providers/tencent-cls/index.ts +1 -0
  124. package/src/scripts/bundle.ts +82 -0
  125. package/src/server/api-routes.ts +98 -0
  126. package/src/server/audit.ts +310 -0
  127. package/src/server/chat-routes.ts +95 -0
  128. package/src/server/config.test.ts +162 -0
  129. package/src/server/config.ts +198 -0
  130. package/src/server/db.ts +115 -0
  131. package/src/server/index.ts +1 -0
  132. package/src/server/mcp-handler.ts +209 -0
  133. package/src/server/mcp-routes.ts +133 -0
  134. package/src/server/mcps-router.ts +133 -0
  135. package/src/server/schema.ts +175 -0
  136. package/src/server/server.ts +163 -0
  137. package/src/web/ChatPage.tsx +1005 -0
  138. package/src/web/McpInspectorPage.tsx +254 -0
  139. package/src/web/ServersPage.tsx +139 -0
  140. package/src/web/main.tsx +600 -0
  141. 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
+ });