natureco-cli 5.4.21 → 5.5.0

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/src/utils/api.js CHANGED
@@ -1,1054 +1,1158 @@
1
- // NatureCo CLI v2.10.1 - Universal LLM Provider Support + MCP Integration
2
- // Supports: OpenAI, Groq, Together, Fireworks, Perplexity, Mistral, DeepSeek, OpenRouter, Ollama, LM Studio, Anthropic
3
-
4
- const fs = require('fs');
5
- const os = require('os');
6
- const path = require('path');
7
- const chalk = require('chalk');
8
- const { getConfig } = require('./config');
9
- const { getToolDefinitions, executeToolCalls } = require('./tool-runner');
10
- const { MCPClient } = require('./mcp-client');
11
- const TB = require('./token-budget');
12
-
13
- // Persistent conversation directory
14
- const CONV_DIR = path.join(os.homedir(), '.natureco', 'conversations');
15
-
16
- // Conversation history for multi-turn chat (deprecated - now using disk storage)
17
- const conversationHistory = new Map();
18
-
19
- // MCP clients (server name -> { client, tools })
20
- const mcpClients = {};
21
-
22
- /**
23
- * Generate default conversation ID based on provider config
24
- */
25
- function generateDefaultConvId() {
26
- const config = getConfig();
27
-
28
- // Use provider URL + model as base for consistent ID
29
- const providerUrl = config.providerUrl || 'default';
30
- const model = config.providerModel || 'default';
31
-
32
- // Create simple hash-like ID from provider + model
33
- const base = `${providerUrl}_${model}`.replace(/[^a-z0-9]/gi, '_').toLowerCase();
34
-
35
- // Return consistent ID (e.g., "groq_llama_3_1_8b_instant")
36
- return base.slice(0, 50); // Limit length
37
- }
38
-
39
- /**
40
- * Load conversation from disk
41
- */
42
- function loadConversation(convId) {
43
- const file = path.join(CONV_DIR, `${convId.replace(/[^a-z0-9]/gi, '_')}.json`);
44
- try {
45
- fs.mkdirSync(CONV_DIR, { recursive: true });
46
- if (fs.existsSync(file)) {
47
- const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
48
- return Array.isArray(parsed) ? parsed : [];
49
- }
50
- } catch (e) {
51
- // Silently fail
52
- }
53
- return [];
54
- }
55
-
56
- /**
57
- * Save conversation to disk
58
- */
59
- function saveConversation(convId, messages) {
60
- const file = path.join(CONV_DIR, `${convId.replace(/[^a-z0-9]/gi, '_')}.json`);
61
- try {
62
- fs.mkdirSync(CONV_DIR, { recursive: true });
63
- // Keep only last 10 messages
64
- fs.writeFileSync(file, JSON.stringify(messages.slice(-(TB.load().conversationOnDisk)), null, 2));
65
- } catch (e) {
66
- // Silently fail
67
- }
68
- }
69
-
70
- /**
71
- * Start MCP servers from config
72
- */
73
- async function startMcpServers() {
74
- const config = getConfig();
75
-
76
- // Skip if MCP is disabled
77
- if (config.mcpEnabled === false) {
78
- debugLog('[MCP] MCP is disabled in config, skipping server startup');
79
- return;
80
- }
81
-
82
- const servers = config.mcpServers || {};
83
-
84
- for (const [name, server] of Object.entries(servers)) {
85
- // Skip disabled servers
86
- if (server.disabled) {
87
- debugLog(`[MCP] Skipping disabled server: ${name}`);
88
- continue;
89
- }
90
-
91
- // Skip if already started
92
- if (mcpClients[name]) {
93
- debugLog(`[MCP] Server already running: ${name}`);
94
- continue;
95
- }
96
-
97
- try {
98
- debugLog(`[MCP] Starting server: ${name}`);
99
-
100
- const client = new MCPClient(server.command, server.args, server.env || {});
101
- await client.start();
102
-
103
- const tools = await client.listTools();
104
- debugLog(`[MCP] Server ${name} loaded ${tools.length} tools`);
105
-
106
- mcpClients[name] = { client, tools };
107
-
108
- } catch (err) {
109
- debugLog(`[MCP] Failed to start server ${name}: ${err.message}`);
110
- }
111
- }
112
- }
113
-
114
- /**
115
- * Stop all MCP servers
116
- */
117
- function stopMcpServers() {
118
- for (const [name, { client }] of Object.entries(mcpClients)) {
119
- try {
120
- debugLog(`[MCP] Stopping server: ${name}`);
121
- client.stop();
122
- } catch (err) {
123
- debugLog(`[MCP] Failed to stop server ${name}: ${err.message}`);
124
- }
125
- }
126
-
127
- // Clear clients
128
- Object.keys(mcpClients).forEach(key => delete mcpClients[key]);
129
- }
130
-
131
- /**
132
- * Get all MCP tools (combined from all servers)
133
- */
134
- function getMcpTools() {
135
- const allTools = [];
136
-
137
- for (const [serverName, { tools }] of Object.entries(mcpClients)) {
138
- for (const tool of tools) {
139
- allTools.push({
140
- ...tool,
141
- _mcpServer: serverName, // Track which server this tool belongs to
142
- });
143
- }
144
- }
145
-
146
- return allTools;
147
- }
148
-
149
- // Groq-incompatible MCP tools (strict validation issues)
150
- const BLOCKED_MCP_TOOLS = ['search_issues', 'search_repositories'];
151
-
152
- /**
153
- * Get MCP tools filtered for AI consumption
154
- * Removes tools that are incompatible with Groq's strict validation
155
- */
156
- function getMcpToolsForAI() {
157
- const tools = getMcpTools();
158
- return tools.filter(t => !BLOCKED_MCP_TOOLS.includes(t.name));
159
- }
160
-
161
- /**
162
- * Normalize MCP tool schema for AI consumption
163
- * Adds hints to number/integer parameters to prevent string conversion
164
- */
165
- function normalizeMcpToolSchema(tool) {
166
- const schema = tool.inputSchema || tool.input_schema || {};
167
-
168
- // Ensure properties exist
169
- if (!schema.properties) return tool;
170
-
171
- // Clone schema to avoid mutating original
172
- const normalizedSchema = JSON.parse(JSON.stringify(schema));
173
-
174
- // Groq sometimes sends strings for number params
175
- // Add coercion hint to description
176
- for (const [key, prop] of Object.entries(normalizedSchema.properties)) {
177
- if (prop.type === 'number' || prop.type === 'integer') {
178
- prop.description = (prop.description || '') + ' (must be a number, not a string)';
179
- }
180
- }
181
-
182
- return { ...tool, inputSchema: normalizedSchema };
183
- }
184
-
185
- /**
186
- * Minimize MCP tool schema to reduce token usage
187
- * Truncates descriptions and removes unnecessary fields
188
- */
189
- function minimizeMcpTool(tool) {
190
- return {
191
- name: tool.name,
192
- description: TB.capMcpDesc(tool.description),
193
- inputSchema: {
194
- type: tool.inputSchema?.type || 'object',
195
- properties: Object.fromEntries(
196
- Object.entries(tool.inputSchema?.properties || {}).map(([k, v]) => [
197
- k,
198
- {
199
- type: v.type,
200
- ...(v.enum ? { enum: v.enum } : {}) // Include enum only if exists
201
- }
202
- ])
203
- ),
204
- required: tool.inputSchema?.required || []
205
- }
206
- };
207
- }
208
-
209
- /**
210
- * Coerce MCP tool parameters to match schema types
211
- */
212
- function coerceMcpParams(tool, params) {
213
- // GitHub MCP uses inputSchema, others may use input_schema
214
- // Try all possible schema locations
215
- const schema = tool.inputSchema?.properties ||
216
- tool.input_schema?.properties ||
217
- tool.parameters?.properties || // fallback
218
- {};
219
-
220
- const coerced = { ...params };
221
-
222
- for (const [key, def] of Object.entries(schema)) {
223
- if (coerced[key] === undefined || coerced[key] === null) continue;
224
-
225
- // Coerce number or integer
226
- if ((def.type === 'number' || def.type === 'integer') && typeof coerced[key] === 'string') {
227
- const num = Number(coerced[key]);
228
- if (!isNaN(num)) {
229
- coerced[key] = num;
230
- }
231
- }
232
-
233
- // Coerce boolean
234
- if (def.type === 'boolean' && typeof coerced[key] === 'string') {
235
- coerced[key] = coerced[key] === 'true' || coerced[key] === '1';
236
- }
237
- }
238
-
239
- return coerced;
240
- }
241
-
242
- /**
243
- * Execute MCP tool call
244
- */
245
- async function executeMcpTool(toolName, toolArgs) {
246
- // Find which server has this tool
247
- for (const [serverName, { client, tools }] of Object.entries(mcpClients)) {
248
- const tool = tools.find(t => t.name === toolName);
249
-
250
- if (tool) {
251
- debugLog(`[MCP] Calling tool ${toolName} on server ${serverName}`);
252
-
253
- try {
254
- // Coerce parameters to match schema types
255
- const coercedArgs = coerceMcpParams(tool, toolArgs);
256
-
257
- const result = await client.callTool(toolName, coercedArgs);
258
-
259
- // MCP returns { content: [{ type: 'text', text: '...' }] }
260
- if (result.content && result.content.length > 0) {
261
- // Extract all text content and join with newlines
262
- const textContents = result.content
263
- .filter(c => c.type === 'text')
264
- .map(c => c.text);
265
-
266
- if (textContents.length > 0) {
267
- let output = textContents.join('\n');
268
-
269
- // Truncate MCP result
270
- const maxChars = TB.load().toolMaxChars;
271
- if (output.length > maxChars) {
272
- output = output.slice(0, maxChars) + '... (truncated)';
273
- }
274
-
275
- return {
276
- success: true,
277
- output: output
278
- };
279
- }
280
- }
281
-
282
- // Fallback: return entire result as JSON
283
- let fallbackOutput = JSON.stringify(result, null, 2);
284
-
285
- // Truncate fallback output too
286
- const maxChars = TB.load().toolMaxChars;
287
- if (fallbackOutput.length > maxChars) {
288
- fallbackOutput = fallbackOutput.slice(0, maxChars) + '... (truncated)';
289
- }
290
-
291
- return {
292
- success: true,
293
- output: fallbackOutput
294
- };
295
-
296
- } catch (err) {
297
- return {
298
- success: false,
299
- error: `MCP tool error: ${err.message}`
300
- };
301
- }
302
- }
303
- }
304
-
305
- return {
306
- success: false,
307
- error: `MCP tool not found: ${toolName}`
308
- };
309
- }
310
-
311
- /**
312
- * Encode tool result for safe transmission
313
- * Works for both MCP and local tools
314
- */
315
- function encodeToolResult(toolResult) {
316
- let content;
317
-
318
- // Handle different result formats
319
- if (typeof toolResult === 'string') {
320
- content = toolResult;
321
- } else if (toolResult.output) {
322
- content = toolResult.output;
323
- } else if (toolResult.success !== undefined) {
324
- // Handle { success: true/false, output/error: ... } format
325
- content = toolResult.success ? (toolResult.output || JSON.stringify(toolResult)) : (toolResult.error || 'Unknown error');
326
- } else {
327
- content = JSON.stringify(toolResult);
328
- }
329
-
330
- // Base64 encode
331
- const encoded = Buffer.from(content).toString('base64');
332
- return `[BASE64_ENCODED_RESULT]: ${encoded}`;
333
- }
334
-
335
- /**
336
- * Check if debug mode is enabled
337
- */
338
- function isDebugEnabled() {
339
- const config = getConfig();
340
- return config.debug === true || config.debug === 'true';
341
- }
342
-
343
- /**
344
- * Debug log (only if debug mode enabled)
345
- */
346
- function debugLog(...args) {
347
- if (isDebugEnabled()) {
348
- console.log(...args);
349
- }
350
- }
351
-
352
- /**
353
- * Get provider configuration from config
354
- */
355
- function getProviderConfig() {
356
- const config = getConfig();
357
-
358
- // Universal provider config (v2.1.0+)
359
- if (config.providerUrl && config.providerApiKey) {
360
- return {
361
- url: config.providerUrl,
362
- apiKey: config.providerApiKey,
363
- model: config.providerModel || 'llama-3.3-70b-versatile',
364
- isAnthropic: config.providerUrl.includes('anthropic.com')
365
- };
366
- }
367
-
368
- // Legacy Groq config (v2.0.x)
369
- if (config.groqApiKey) {
370
- return {
371
- url: 'https://api.groq.com/openai/v1',
372
- apiKey: config.groqApiKey,
373
- model: config.groqModel || 'llama-3.3-70b-versatile',
374
- isAnthropic: false
375
- };
376
- }
377
-
378
- return null;
379
- }
380
-
381
- /**
382
- * Format tool definitions for OpenAI-compatible APIs
383
- */
384
- function formatToolsForOpenAI() {
385
- const config = getConfig();
386
- const localTools = getToolDefinitions();
387
-
388
- // Only add MCP tools if enabled
389
- let allTools = [...localTools];
390
- if (config.mcpEnabled !== false) {
391
- const mcpTools = getMcpToolsForAI().map(minimizeMcpTool);
392
- const normalizedMcpTools = mcpTools.map(tool => normalizeMcpToolSchema(tool));
393
- allTools = [...allTools, ...normalizedMcpTools];
394
- }
395
-
396
- return allTools.map(tool => ({
397
- type: 'function',
398
- function: {
399
- name: tool.name,
400
- description: tool.description,
401
- parameters: tool.inputSchema || tool.input_schema || { type: 'object', properties: {} }
402
- }
403
- }));
404
- }
405
-
406
- /**
407
- * Format tool definitions for Anthropic API
408
- */
409
- function formatToolsForAnthropic() {
410
- const config = getConfig();
411
- const localTools = getToolDefinitions();
412
-
413
- // Only add MCP tools if enabled
414
- let allTools = [...localTools];
415
- if (config.mcpEnabled !== false) {
416
- const mcpTools = getMcpToolsForAI().map(minimizeMcpTool);
417
- const normalizedMcpTools = mcpTools.map(tool => normalizeMcpToolSchema(tool));
418
- allTools = [...allTools, ...normalizedMcpTools];
419
- }
420
-
421
- return allTools.map(tool => ({
422
- name: tool.name,
423
- description: tool.description,
424
- input_schema: tool.inputSchema || tool.input_schema || { type: 'object', properties: {} }
425
- }));
426
- }
427
-
428
- /**
429
- * Send message to OpenAI-compatible provider (Groq, OpenAI, Together, etc.)
430
- */
431
- async function sendMessageOpenAICompatible(providerConfig, messages, tools) {
432
- const baseUrl = providerConfig.url.replace(/\/+$/, '');
433
- // MiniMax özel endpoint tespiti
434
- const isMiniMax = baseUrl.includes('minimax.io') || baseUrl.includes('minimaxi.com') || baseUrl.includes('minimax.cn');
435
- const endpoint = isMiniMax
436
- ? `${baseUrl}/v1/text/chatcompletion_v2`
437
- : `${baseUrl}/chat/completions`;
438
- const requestBody = {
439
- model: providerConfig.model,
440
- messages: messages,
441
- temperature: 0.7,
442
- max_tokens: 2048,
443
- };
444
-
445
- // NatureCo için tool calling desteklenmiyor
446
- if (!providerConfig.url.includes('api.natureco.me')) {
447
- if (tools && tools.length > 0) {
448
- requestBody.tools = tools;
449
- requestBody.tool_choice = 'auto';
450
- }
451
- }
452
-
453
- const response = await fetch(endpoint, {
454
- method: 'POST',
455
- headers: {
456
- 'Authorization': `Bearer ${providerConfig.apiKey}`,
457
- 'Content-Type': 'application/json',
458
- },
459
- body: JSON.stringify(requestBody),
460
- });
461
-
462
- if (!response.ok) {
463
- const errorText = await response.text();
464
- throw new Error(`Provider API error: ${response.status} - ${errorText}`);
465
- }
466
-
467
- const data = await response.json();
468
- const content = data.choices?.[0]?.message?.content
469
- || data.choices?.[0]?.text
470
- || data.response
471
- || data.content
472
- || '';
473
- return {
474
- role: 'assistant',
475
- content,
476
- tool_calls: data.choices?.[0]?.message?.tool_calls || undefined,
477
- usage: data.usage || undefined,
478
- };
479
- }
480
-
481
- /**
482
- * Send message to Anthropic API
483
- */
484
- async function sendMessageAnthropic(providerConfig, messages, tools) {
485
- const endpoint = `${providerConfig.url}/v1/messages`;
486
-
487
- // Anthropic requires system message separate
488
- const systemMessage = messages.find(m => m.role === 'system');
489
- const userMessages = messages.filter(m => m.role !== 'system');
490
-
491
- const response = await fetch(endpoint, {
492
- method: 'POST',
493
- headers: {
494
- 'x-api-key': providerConfig.apiKey,
495
- 'anthropic-version': '2023-06-01',
496
- 'Content-Type': 'application/json',
497
- },
498
- body: JSON.stringify({
499
- model: providerConfig.model,
500
- max_tokens: 2000,
501
- system: systemMessage?.content || '',
502
- messages: userMessages,
503
- tools: tools,
504
- }),
505
- });
506
-
507
- if (!response.ok) {
508
- const errorText = await response.text();
509
- throw new Error(`Anthropic API error: ${response.status} - ${errorText}`);
510
- }
511
-
512
- const data = await response.json();
513
-
514
- // Convert Anthropic response to OpenAI format
515
- const content = data.content.find(c => c.type === 'text')?.text || '';
516
- const toolCalls = data.content
517
- .filter(c => c.type === 'tool_use')
518
- .map(c => ({
519
- id: c.id,
520
- type: 'function',
521
- function: {
522
- name: c.name,
523
- arguments: JSON.stringify(c.input)
524
- }
525
- }));
526
-
527
- return {
528
- role: 'assistant',
529
- content: content,
530
- tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
531
- usage: data.usage || undefined,
532
- };
533
- }
534
-
535
- /**
536
- * Send message with tool support (universal)
537
- */
538
- async function sendMessageToProvider(apiKey, message, conversationId = null, systemPrompt = null, options = {}) {
539
- const providerConfig = getProviderConfig();
540
-
541
- if (!providerConfig) {
542
- throw new Error(
543
- 'Provider not configured. Set with:\n' +
544
- ' natureco config set providerUrl https://api.groq.com/openai/v1\n' +
545
- ' natureco config set providerApiKey gsk_xxx\n' +
546
- ' natureco config set providerModel llama-3.3-70b-versatile'
547
- );
548
- }
549
-
550
- // Start MCP servers if not already started
551
- if (Object.keys(mcpClients).length === 0) {
552
- await startMcpServers();
553
- }
554
-
555
- // Get or create conversation history (load from disk)
556
- // Use consistent ID based on provider config instead of timestamp
557
- const convId = conversationId || generateDefaultConvId();
558
- const history = loadConversation(convId);
559
-
560
- // Augment system prompt with project AGENTS.md instructions
561
- const agentsMd = require('./agents-md');
562
- const augmentedPrompt = agentsMd.injectIntoPrompt(systemPrompt || '', options?.cwd || process.cwd());
563
-
564
- // Build messages
565
- let messages = [];
566
- if (augmentedPrompt) {
567
- messages.push({ role: 'system', content: augmentedPrompt });
568
- }
569
- messages.push(...history);
570
- messages.push({ role: 'user', content: message });
571
-
572
- // Get tool definitions (local + MCP) — skip if noTools flag set (chat mode)
573
- const tools = options.noTools
574
- ? []
575
- : (providerConfig.isAnthropic ? formatToolsForAnthropic() : formatToolsForOpenAI());
576
-
577
- debugLog('\n[Provider] Sending request...');
578
- debugLog('[Provider] URL:', providerConfig.url);
579
- debugLog('[Provider] Model:', providerConfig.model);
580
- debugLog('[Provider] Type:', providerConfig.isAnthropic ? 'Anthropic' : 'OpenAI-compatible');
581
- debugLog('[Provider] Messages:', messages.length);
582
- debugLog('[Provider] Tools:', tools.length, `(${Object.keys(mcpClients).length} MCP servers)`);
583
-
584
- // Tool execution loop (max 10 iterations)
585
- let iteration = 0;
586
- const maxIterations = 10;
587
- let finalResponse = null;
588
- const stream = (options.stream ?? options.noStream === undefined) !== false &&
589
- !providerConfig.url.includes('api.natureco.me');
590
-
591
- while (iteration < maxIterations) {
592
- iteration++;
593
- debugLog(`\n[Provider] Iteration ${iteration}/${maxIterations}`);
594
-
595
- let assistantMessage;
596
-
597
- if (stream) {
598
- const result = await streamProviderCompletion(providerConfig, messages, tools);
599
- if (result.type === 'text') {
600
- messages.push({ role: 'assistant', content: result.content });
601
- finalResponse = result.content;
602
- break;
603
- }
604
- assistantMessage = result.message;
605
- } else {
606
- assistantMessage = providerConfig.isAnthropic
607
- ? await sendMessageAnthropic(providerConfig, messages, tools)
608
- : await sendMessageOpenAICompatible(providerConfig, messages, tools);
609
- }
610
-
611
- if (!assistantMessage) {
612
- return {
613
- reply: 'No response from provider',
614
- conversation_id: convId,
615
- message_id: `msg_${Date.now()}`,
616
- success: false
617
- };
618
- }
619
-
620
- // Track token usage if available
621
- if (assistantMessage.usage) {
622
- TB.trackUsage(convId, {
623
- input: assistantMessage.usage.prompt_tokens || assistantMessage.usage.input_tokens || 0,
624
- output: assistantMessage.usage.completion_tokens || assistantMessage.usage.output_tokens || 0
625
- });
626
- }
627
-
628
- debugLog('[Provider] Response type:', assistantMessage?.tool_calls ? 'tool_calls' : 'text');
629
-
630
- // Add assistant message to history
631
- messages.push(assistantMessage);
632
-
633
- // Check for tool calls
634
- const hasToolCalls = assistantMessage?.tool_calls?.length > 0;
635
- if (hasToolCalls) {
636
- debugLog(`[Provider] Tool calls: ${assistantMessage.tool_calls.length}`);
637
-
638
- // Separate local and MCP tool calls
639
- const toolCalls = assistantMessage.tool_calls.map(tc => ({
640
- id: tc.id,
641
- name: tc.function.name,
642
- input: JSON.parse(tc.function.arguments)
643
- }));
644
-
645
- const toolResults = [];
646
- const SPINNER_FRAMES = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
647
-
648
- for (const toolCall of toolCalls) {
649
- // Spinner başlat
650
- let frameIdx = 0;
651
- const inputPreview = JSON.stringify(toolCall.input).slice(0, 50);
652
- const spinner = setInterval(() => {
653
- process.stdout.write(`\r ${chalk.cyan(SPINNER_FRAMES[frameIdx++ % SPINNER_FRAMES.length])} ${chalk.gray(toolCall.name + ' — ' + inputPreview)}`);
654
- }, 80);
655
-
656
- // Check if this is an MCP tool
657
- const mcpTools = getMcpTools();
658
- const isMcpTool = mcpTools.find(t => t.name === toolCall.name);
659
- let result;
660
-
661
- if (isMcpTool) {
662
- debugLog(`[MCP] Executing tool: ${toolCall.name}`);
663
- result = await executeMcpTool(toolCall.name, toolCall.input);
664
- toolResults.push({ id: toolCall.id, name: toolCall.name, result });
665
- } else {
666
- debugLog(`[Local] Executing tool: ${toolCall.name}`);
667
- const localResults = await executeToolCalls([toolCall]);
668
- toolResults.push(...localResults);
669
- result = localResults[0]?.result;
670
- }
671
-
672
- // Spinner durdur, sonucu göster
673
- clearInterval(spinner);
674
- const success = result?.success !== false;
675
- process.stdout.write(`\r ${success ? chalk.green('✓') : chalk.red('✗')} ${chalk.cyan(toolCall.name)} ${chalk.gray('— ' + inputPreview)}\n`);
676
- }
677
-
678
- // Add tool results to messages (base64 encoded for safety)
679
- for (const result of toolResults) {
680
- // Encode tool result (works for both MCP and local tools)
681
- const encodedContent = encodeToolResult(result.result);
682
-
683
- messages.push({
684
- role: 'tool',
685
- tool_call_id: result.id,
686
- name: result.name,
687
- content: encodedContent
688
- });
689
- }
690
-
691
- // Continue loop to get final response
692
- continue;
693
- }
694
-
695
- // No tool calls, we have final response
696
- finalResponse = assistantMessage?.content;
697
- break;
698
- }
699
-
700
- if (iteration >= maxIterations) {
701
- debugLog('\n[Provider] Max iterations reached');
702
- finalResponse = finalResponse || 'Max tool execution iterations reached.';
703
- }
704
-
705
- // Apply token budget trimming
706
- messages = TB.trimMessages(messages);
707
-
708
- // Save to conversation history (only user and final assistant message)
709
- history.push({ role: 'user', content: message });
710
- history.push({ role: 'assistant', content: finalResponse });
711
-
712
- // Save to disk (automatically keeps last 20 messages)
713
- saveConversation(convId, history);
714
-
715
- return {
716
- reply: finalResponse,
717
- conversation_id: convId,
718
- message_id: `msg_${Date.now()}`,
719
- success: true
720
- };
721
- }
722
-
723
- /**
724
- * Clear conversation history
725
- */
726
- function clearConversation(conversationId) {
727
- if (conversationId) {
728
- // Delete from disk
729
- const file = path.join(CONV_DIR, `${conversationId.replace(/[^a-z0-9]/gi, '_')}.json`);
730
- try {
731
- if (fs.existsSync(file)) {
732
- fs.unlinkSync(file);
733
- }
734
- } catch (e) {
735
- // Silently fail
736
- }
737
- // Also clear from memory (legacy)
738
- conversationHistory.delete(conversationId);
739
- } else {
740
- // Clear all conversations from disk
741
- try {
742
- if (fs.existsSync(CONV_DIR)) {
743
- const files = fs.readdirSync(CONV_DIR);
744
- files.forEach(file => {
745
- fs.unlinkSync(path.join(CONV_DIR, file));
746
- });
747
- }
748
- } catch (e) {
749
- // Silently fail
750
- }
751
- // Also clear from memory (legacy)
752
- conversationHistory.clear();
753
- }
754
- }
755
-
756
- /**
757
- * Legacy function for compatibility
758
- * Now supports custom system prompts for different platforms (terminal, WhatsApp, etc.)
759
- * @param {string} chatSystemPrompt - System prompt from chat.js (skills + memory + agents)
760
- */
761
- async function sendMessage(apiKey, botId, message, conversationId = null, chatSystemPrompt = '', options = {}) {
762
- // Handle legacy 6th param (toolDefinitions array was passed)
763
- if (Array.isArray(options)) options = {};
764
- const providerConfig = getProviderConfig();
765
-
766
- // Get user's home directory
767
- const homeDir = os.homedir();
768
-
769
- // Load memory to get botName
770
- const { loadMemory } = require('./memory');
771
- const mem = loadMemory(botId);
772
-
773
- // Get config to check MCP status
774
- const config = getConfig();
775
-
776
- // NatureCo — minimal system prompt, skip tool descriptions/MCP
777
- if (providerConfig && providerConfig.url.includes('api.natureco.me')) {
778
- const prompt = chatSystemPrompt || 'Sen yardımcı bir AI asistansın.';
779
- return sendMessageToProvider(apiKey, message, conversationId, prompt, options);
780
- }
781
-
782
- // Minimal base prompt (~200 token)
783
- const toolDefs = getToolDefinitions();
784
- const toolsDesc = toolDefs.map(t => t.name).join(', ');
785
- let systemPrompt = `Assistant. Tools: ${toolsDesc}. Home: ${homeDir}.`;
786
-
787
- // Skill prompts only, max 500 chars
788
- if (chatSystemPrompt) {
789
- systemPrompt += '\n' + chatSystemPrompt.slice(0, TB.load().systemPromptMaxChars);
790
- }
791
-
792
- return sendMessageToProvider(apiKey, message, conversationId, systemPrompt, options);
793
- }
794
-
795
- /**
796
- * Validate API key against NatureCo backend
797
- * Returns { valid, error, user }
798
- */
799
- async function validateApiKey(apiKey) {
800
- const result = { valid: false, error: null, user: null };
801
- if (!apiKey) {
802
- result.error = 'API key boş olamaz';
803
- return result;
804
- }
805
- try {
806
- const res = await fetch('https://api.natureco.me/api/v1/user/me', {
807
- headers: {
808
- 'Authorization': `Bearer ${apiKey}`,
809
- 'Content-Type': 'application/json',
810
- },
811
- });
812
- if (res.ok) {
813
- const body = await res.json();
814
- result.valid = true;
815
- result.user = body.user || body.data || body;
816
- return result;
817
- }
818
- let errorBody = '';
819
- try { errorBody = await res.text(); } catch {}
820
- result.error = `API doğrulama hatası (${res.status}): ${errorBody || 'Geçersiz API key'}`;
821
- return result;
822
- } catch (e) {
823
- result.error = `Bağlantı hatası: ${e.message}`;
824
- return result;
825
- }
826
- }
827
-
828
- /**
829
- * Get bots (not used in v2.x, kept for compatibility)
830
- */
831
- async function getBots(apiKey) {
832
- const config = getConfig();
833
- const providerConfig = getProviderConfig();
834
-
835
- // NatureCo provider — gerçek bot listesini API'den çek
836
- if (config.providerUrl && config.providerUrl.includes('natureco.me')) {
837
- try {
838
- const res = await fetch('https://api.natureco.me/api/v1/bots', {
839
- headers: {
840
- 'Authorization': `Bearer ${config.providerApiKey || apiKey}`,
841
- 'Content-Type': 'application/json',
842
- },
843
- });
844
- if (res.ok) {
845
- const data = await res.json();
846
- const bots = Array.isArray(data) ? data : (data.bots || data.data || []);
847
- if (bots.length > 0) {
848
- return {
849
- bots: bots.map(b => ({
850
- id: b.id,
851
- name: b.name,
852
- ai_provider: b.ai_provider || 'natureco',
853
- model: b.model || 'natureco-default',
854
- system_prompt: b.system_prompt || '',
855
- }))
856
- };
857
- }
858
- }
859
- } catch (e) {
860
- debugLog('[getBots] NatureCo API error:', e.message);
861
- return { bots: [], error: e.message };
862
- }
863
- }
864
-
865
- // Diğer provider'lar universal provider döndür
866
- const providerName = providerConfig?.isAnthropic ? 'Anthropic' : 'OpenAI-compatible';
867
- const botName = config.botName || `Universal Provider (${providerName})`;
868
- return {
869
- bots: [
870
- {
871
- id: 'universal-provider',
872
- name: botName,
873
- ai_provider: providerName,
874
- model: providerConfig?.model || 'unknown'
875
- }
876
- ]
877
- };
878
- }
879
-
880
- // ── Streaming Support ────────────────────────────────────────────────────────────
881
-
882
- async function streamProviderCompletion(providerConfig, messages, tools) {
883
- if (providerConfig.isAnthropic) {
884
- return streamAnthropicCompletion(providerConfig, messages);
885
- }
886
- return streamOpenAICompletion(providerConfig, messages, tools);
887
- }
888
-
889
- async function streamOpenAICompletion(providerConfig, messages, tools) {
890
- const baseUrl = providerConfig.url.replace(/\/+$/, '');
891
- // MiniMax özel endpoint tespiti (streaming için de aynı)
892
- const isMiniMax = baseUrl.includes('minimax.io') || baseUrl.includes('minimaxi.com') || baseUrl.includes('minimax.cn');
893
- const endpoint = isMiniMax
894
- ? `${baseUrl}/v1/text/chatcompletion_v2`
895
- : `${baseUrl}/chat/completions`;
896
-
897
- const requestBody = {
898
- model: providerConfig.model,
899
- messages,
900
- temperature: 0.7,
901
- max_tokens: 2000,
902
- stream: true,
903
- };
904
- if (tools && tools.length > 0) {
905
- requestBody.tools = tools;
906
- requestBody.tool_choice = 'auto';
907
- }
908
-
909
- const response = await fetch(endpoint, {
910
- method: 'POST',
911
- headers: {
912
- 'Authorization': `Bearer ${providerConfig.apiKey}`,
913
- 'Content-Type': 'application/json',
914
- },
915
- body: JSON.stringify(requestBody),
916
- });
917
-
918
- if (!response.ok) {
919
- throw new Error(`Provider API error: ${response.status} - ${await response.text()}`);
920
- }
921
-
922
- const reader = response.body.getReader();
923
- const decoder = new TextDecoder();
924
- let fullText = '';
925
- const toolCalls = [];
926
- let hasToolCalls = false;
927
-
928
- while (true) {
929
- const { done, value } = await reader.read();
930
- if (done) break;
931
-
932
- const chunk = decoder.decode(value, { stream: true });
933
- const lines = chunk.split('\n').filter(l => l.startsWith('data: '));
934
-
935
- for (const line of lines) {
936
- const data = line.slice(6).trim();
937
- if (data === '[DONE]') continue;
938
- try {
939
- const parsed = JSON.parse(data);
940
- const delta = parsed.choices?.[0]?.delta;
941
- if (!delta) continue;
942
-
943
- if (delta.tool_calls) {
944
- hasToolCalls = true;
945
- for (const tc of delta.tool_calls) {
946
- const idx = tc.index;
947
- if (!toolCalls[idx]) {
948
- toolCalls[idx] = { id: tc.id || '', type: 'function', function: { name: '', arguments: '' } };
949
- }
950
- if (tc.id) toolCalls[idx].id = tc.id;
951
- if (tc.function?.name) toolCalls[idx].function.name += tc.function.name;
952
- if (tc.function?.arguments) toolCalls[idx].function.arguments += tc.function.arguments;
953
- }
954
- }
955
-
956
- const token = delta.content || '';
957
- if (token) {
958
- if (!hasToolCalls) process.stdout.write(token);
959
- fullText += token;
960
- }
961
- } catch {}
962
- }
963
- }
964
-
965
- if (hasToolCalls) {
966
- process.stdout.write('\n');
967
- return {
968
- type: 'tool_calls',
969
- message: {
970
- role: 'assistant',
971
- content: fullText || null,
972
- tool_calls: toolCalls.map(tc => ({
973
- id: tc.id,
974
- type: tc.type,
975
- function: { name: tc.function.name, arguments: tc.function.arguments }
976
- }))
977
- }
978
- };
979
- }
980
-
981
- process.stdout.write('\n');
982
- return { type: 'text', content: fullText };
983
- }
984
-
985
- async function streamAnthropicCompletion(providerConfig, messages) {
986
- const endpoint = `${providerConfig.url}/v1/messages`;
987
-
988
- const systemMessage = messages.find(m => m.role === 'system');
989
- const userMessages = messages.filter(m => m.role !== 'system');
990
-
991
- const response = await fetch(endpoint, {
992
- method: 'POST',
993
- headers: {
994
- 'x-api-key': providerConfig.apiKey,
995
- 'anthropic-version': '2023-06-01',
996
- 'Content-Type': 'application/json',
997
- },
998
- body: JSON.stringify({
999
- model: providerConfig.model,
1000
- max_tokens: 2000,
1001
- system: systemMessage?.content || '',
1002
- messages: userMessages,
1003
- stream: true,
1004
- }),
1005
- });
1006
-
1007
- if (!response.ok) {
1008
- throw new Error(`Anthropic API error: ${response.status} - ${await response.text()}`);
1009
- }
1010
-
1011
- const reader = response.body.getReader();
1012
- const decoder = new TextDecoder();
1013
- let fullText = '';
1014
-
1015
- while (true) {
1016
- const { done, value } = await reader.read();
1017
- if (done) break;
1018
-
1019
- const chunk = decoder.decode(value, { stream: true });
1020
- const lines = chunk.split('\n').filter(l => l.startsWith('data: '));
1021
-
1022
- for (const line of lines) {
1023
- const data = line.slice(6).trim();
1024
- if (data === '[DONE]') continue;
1025
- try {
1026
- const parsed = JSON.parse(data);
1027
- const token = parsed.delta?.text || '';
1028
- if (token) {
1029
- process.stdout.write(token);
1030
- fullText += token;
1031
- }
1032
- } catch {}
1033
- }
1034
- }
1035
-
1036
- process.stdout.write('\n');
1037
- return { type: 'text', content: fullText };
1038
- }
1039
-
1040
- module.exports = {
1041
- sendMessage,
1042
- sendMessageToProvider,
1043
- validateApiKey,
1044
- getBots,
1045
- clearConversation,
1046
- getProviderConfig,
1047
- startMcpServers,
1048
- stopMcpServers,
1049
- getMcpTools,
1050
- streamProviderCompletion,
1051
- streamOpenAICompletion,
1052
- streamAnthropicCompletion,
1053
- _sendMessage: sendMessage,
1054
- };
1
+ // NatureCo CLI v2.10.1 - Universal LLM Provider Support + MCP Integration
2
+ // Supports: OpenAI, Groq, Together, Fireworks, Perplexity, Mistral, DeepSeek, OpenRouter, Ollama, LM Studio, Anthropic
3
+
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const chalk = require('chalk');
8
+ const { getConfig } = require('./config');
9
+ const { getToolDefinitions, executeToolCalls } = require('./tool-runner');
10
+ const { MCPClient } = require('./mcp-client');
11
+ const TB = require('./token-budget');
12
+
13
+ /**
14
+ * v5.5.0: Provider-specific format detection
15
+ * Groq, OpenAI, Anthropic, Mistral, DeepSeek, OpenRouter, Ollama, MiniMax
16
+ */
17
+ function detectProvider(providerUrl, model) {
18
+ const url = (providerUrl || '').toLowerCase();
19
+ const m = (model || '').toLowerCase();
20
+ if (url.includes('anthropic.com') || m.includes('claude')) return 'anthropic';
21
+ if (url.includes('groq.com') || m.includes('groq') || m.includes('llama-3') || m.includes('mixtral')) return 'groq';
22
+ if (url.includes('openrouter.ai')) return 'openrouter';
23
+ if (url.includes('api.deepseek.com') || m.includes('deepseek')) return 'deepseek';
24
+ if (url.includes('mistral.ai') || m.includes('mistral') || m.includes('codestral')) return 'mistral';
25
+ if (url.includes('together.xyz') || m.includes('together')) return 'together';
26
+ if (url.includes('fireworks.ai') || m.includes('fireworks')) return 'fireworks';
27
+ if (url.includes('perplexity.ai') || m.includes('pplx') || m.includes('sonar')) return 'perplexity';
28
+ if (url.includes('localhost') || url.includes('127.0.0.1') || url.includes('ollama')) return 'ollama';
29
+ if (url.includes('minimax.io') || url.includes('minimax')) return 'minimax';
30
+ return 'openai'; // default
31
+ }
32
+
33
+ /**
34
+ * v5.5.0: Tool definitions'ı provider'a göre normalize et
35
+ * - OpenAI/Groq/Mistral/DeepSeek/OpenRouter: tool_choice, function calling OK
36
+ * - Anthropic: tools, system ayrı, content array
37
+ * - Ollama: tool support sınırlı, genelde yok
38
+ * - Perplexity: tool support yok
39
+ */
40
+ function normalizeToolsForProvider(tools, provider) {
41
+ if (!tools || tools.length === 0) return tools;
42
+ if (provider === 'ollama' || provider === 'perplexity') {
43
+ // Bu providerlar tool support etmiyor - bos dondur
44
+ return [];
45
+ }
46
+ if (provider === 'anthropic') {
47
+ // Anthropic tools format: { name, description, input_schema }
48
+ return tools.map(t => ({
49
+ name: t.function.name,
50
+ description: t.function.description,
51
+ input_schema: t.function.parameters || { type: 'object', properties: {} }
52
+ }));
53
+ }
54
+ // OpenAI-compatible (Groq, Mistral, DeepSeek, OpenRouter, MiniMax, Together, Fireworks)
55
+ return tools;
56
+ }
57
+
58
+ /**
59
+ * v5.5.0: Tool call'ları provider'a göre parse et
60
+ * OpenAI: tool_calls[].function.arguments (string)
61
+ * Anthropic: content[].type=tool_use, input (object)
62
+ * Ollama: genelde yok
63
+ */
64
+ function parseToolCallsFromResponse(message, provider) {
65
+ if (provider === 'anthropic') {
66
+ // Anthropic: content array içinde tool_use blokları var
67
+ if (Array.isArray(message.content)) {
68
+ const toolUses = message.content.filter(c => c.type === 'tool_use');
69
+ return toolUses.map(tu => ({
70
+ id: tu.id,
71
+ type: 'function',
72
+ function: {
73
+ name: tu.name,
74
+ arguments: JSON.stringify(tu.input || {})
75
+ }
76
+ }));
77
+ }
78
+ return [];
79
+ }
80
+ // OpenAI-compatible
81
+ return message.tool_calls || [];
82
+ }
83
+
84
+ /**
85
+ * v5.5.0: System mesajı provider'a göre ayarla
86
+ * - OpenAI: messages[].role=system
87
+ * - Anthropic: ayrı 'system' field
88
+ */
89
+ function buildRequestBody(messages, model, options, provider) {
90
+ if (provider === 'anthropic') {
91
+ // System mesajını ayır
92
+ const systemMsg = messages.find(m => m.role === 'system');
93
+ const userMsgs = messages.filter(m => m.role !== 'system');
94
+ return {
95
+ model,
96
+ messages: userMsgs.map(m => ({
97
+ role: m.role,
98
+ content: m.content
99
+ })),
100
+ system: systemMsg ? systemMsg.content : undefined,
101
+ max_tokens: options.max_tokens || 4096,
102
+ temperature: options.temperature || 0.7,
103
+ ...(options.tools && options.tools.length > 0 ? { tools: options.tools } : {})
104
+ };
105
+ }
106
+ // OpenAI-compatible
107
+ return {
108
+ model,
109
+ messages,
110
+ max_tokens: options.max_tokens || 4096,
111
+ temperature: options.temperature || 0.7,
112
+ ...(options.tools && options.tools.length > 0 ? { tools: options.tools, tool_choice: 'auto' } : {})
113
+ };
114
+ }
115
+
116
+
117
+ // Persistent conversation directory
118
+ const CONV_DIR = path.join(os.homedir(), '.natureco', 'conversations');
119
+
120
+ // Conversation history for multi-turn chat (deprecated - now using disk storage)
121
+ const conversationHistory = new Map();
122
+
123
+ // MCP clients (server name -> { client, tools })
124
+ const mcpClients = {};
125
+
126
+ /**
127
+ * Generate default conversation ID based on provider config
128
+ */
129
+ function generateDefaultConvId() {
130
+ const config = getConfig();
131
+
132
+ // Use provider URL + model as base for consistent ID
133
+ const providerUrl = config.providerUrl || 'default';
134
+ const model = config.providerModel || 'default';
135
+
136
+ // Create simple hash-like ID from provider + model
137
+ const base = `${providerUrl}_${model}`.replace(/[^a-z0-9]/gi, '_').toLowerCase();
138
+
139
+ // Return consistent ID (e.g., "groq_llama_3_1_8b_instant")
140
+ return base.slice(0, 50); // Limit length
141
+ }
142
+
143
+ /**
144
+ * Load conversation from disk
145
+ */
146
+ function loadConversation(convId) {
147
+ const file = path.join(CONV_DIR, `${convId.replace(/[^a-z0-9]/gi, '_')}.json`);
148
+ try {
149
+ fs.mkdirSync(CONV_DIR, { recursive: true });
150
+ if (fs.existsSync(file)) {
151
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
152
+ return Array.isArray(parsed) ? parsed : [];
153
+ }
154
+ } catch (e) {
155
+ // Silently fail
156
+ }
157
+ return [];
158
+ }
159
+
160
+ /**
161
+ * Save conversation to disk
162
+ */
163
+ function saveConversation(convId, messages) {
164
+ const file = path.join(CONV_DIR, `${convId.replace(/[^a-z0-9]/gi, '_')}.json`);
165
+ try {
166
+ fs.mkdirSync(CONV_DIR, { recursive: true });
167
+ // Keep only last 10 messages
168
+ fs.writeFileSync(file, JSON.stringify(messages.slice(-(TB.load().conversationOnDisk)), null, 2));
169
+ } catch (e) {
170
+ // Silently fail
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Start MCP servers from config
176
+ */
177
+ async function startMcpServers() {
178
+ const config = getConfig();
179
+
180
+ // Skip if MCP is disabled
181
+ if (config.mcpEnabled === false) {
182
+ debugLog('[MCP] MCP is disabled in config, skipping server startup');
183
+ return;
184
+ }
185
+
186
+ const servers = config.mcpServers || {};
187
+
188
+ for (const [name, server] of Object.entries(servers)) {
189
+ // Skip disabled servers
190
+ if (server.disabled) {
191
+ debugLog(`[MCP] Skipping disabled server: ${name}`);
192
+ continue;
193
+ }
194
+
195
+ // Skip if already started
196
+ if (mcpClients[name]) {
197
+ debugLog(`[MCP] Server already running: ${name}`);
198
+ continue;
199
+ }
200
+
201
+ try {
202
+ debugLog(`[MCP] Starting server: ${name}`);
203
+
204
+ const client = new MCPClient(server.command, server.args, server.env || {});
205
+ await client.start();
206
+
207
+ const tools = await client.listTools();
208
+ debugLog(`[MCP] Server ${name} loaded ${tools.length} tools`);
209
+
210
+ mcpClients[name] = { client, tools };
211
+
212
+ } catch (err) {
213
+ debugLog(`[MCP] Failed to start server ${name}: ${err.message}`);
214
+ }
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Stop all MCP servers
220
+ */
221
+ function stopMcpServers() {
222
+ for (const [name, { client }] of Object.entries(mcpClients)) {
223
+ try {
224
+ debugLog(`[MCP] Stopping server: ${name}`);
225
+ client.stop();
226
+ } catch (err) {
227
+ debugLog(`[MCP] Failed to stop server ${name}: ${err.message}`);
228
+ }
229
+ }
230
+
231
+ // Clear clients
232
+ Object.keys(mcpClients).forEach(key => delete mcpClients[key]);
233
+ }
234
+
235
+ /**
236
+ * Get all MCP tools (combined from all servers)
237
+ */
238
+ function getMcpTools() {
239
+ const allTools = [];
240
+
241
+ for (const [serverName, { tools }] of Object.entries(mcpClients)) {
242
+ for (const tool of tools) {
243
+ allTools.push({
244
+ ...tool,
245
+ _mcpServer: serverName, // Track which server this tool belongs to
246
+ });
247
+ }
248
+ }
249
+
250
+ return allTools;
251
+ }
252
+
253
+ // Groq-incompatible MCP tools (strict validation issues)
254
+ const BLOCKED_MCP_TOOLS = ['search_issues', 'search_repositories'];
255
+
256
+ /**
257
+ * Get MCP tools filtered for AI consumption
258
+ * Removes tools that are incompatible with Groq's strict validation
259
+ */
260
+ function getMcpToolsForAI() {
261
+ const tools = getMcpTools();
262
+ return tools.filter(t => !BLOCKED_MCP_TOOLS.includes(t.name));
263
+ }
264
+
265
+ /**
266
+ * Normalize MCP tool schema for AI consumption
267
+ * Adds hints to number/integer parameters to prevent string conversion
268
+ */
269
+ function normalizeMcpToolSchema(tool) {
270
+ const schema = tool.inputSchema || tool.input_schema || {};
271
+
272
+ // Ensure properties exist
273
+ if (!schema.properties) return tool;
274
+
275
+ // Clone schema to avoid mutating original
276
+ const normalizedSchema = JSON.parse(JSON.stringify(schema));
277
+
278
+ // Groq sometimes sends strings for number params
279
+ // Add coercion hint to description
280
+ for (const [key, prop] of Object.entries(normalizedSchema.properties)) {
281
+ if (prop.type === 'number' || prop.type === 'integer') {
282
+ prop.description = (prop.description || '') + ' (must be a number, not a string)';
283
+ }
284
+ }
285
+
286
+ return { ...tool, inputSchema: normalizedSchema };
287
+ }
288
+
289
+ /**
290
+ * Minimize MCP tool schema to reduce token usage
291
+ * Truncates descriptions and removes unnecessary fields
292
+ */
293
+ function minimizeMcpTool(tool) {
294
+ return {
295
+ name: tool.name,
296
+ description: TB.capMcpDesc(tool.description),
297
+ inputSchema: {
298
+ type: tool.inputSchema?.type || 'object',
299
+ properties: Object.fromEntries(
300
+ Object.entries(tool.inputSchema?.properties || {}).map(([k, v]) => [
301
+ k,
302
+ {
303
+ type: v.type,
304
+ ...(v.enum ? { enum: v.enum } : {}) // Include enum only if exists
305
+ }
306
+ ])
307
+ ),
308
+ required: tool.inputSchema?.required || []
309
+ }
310
+ };
311
+ }
312
+
313
+ /**
314
+ * Coerce MCP tool parameters to match schema types
315
+ */
316
+ function coerceMcpParams(tool, params) {
317
+ // GitHub MCP uses inputSchema, others may use input_schema
318
+ // Try all possible schema locations
319
+ const schema = tool.inputSchema?.properties ||
320
+ tool.input_schema?.properties ||
321
+ tool.parameters?.properties || // fallback
322
+ {};
323
+
324
+ const coerced = { ...params };
325
+
326
+ for (const [key, def] of Object.entries(schema)) {
327
+ if (coerced[key] === undefined || coerced[key] === null) continue;
328
+
329
+ // Coerce number or integer
330
+ if ((def.type === 'number' || def.type === 'integer') && typeof coerced[key] === 'string') {
331
+ const num = Number(coerced[key]);
332
+ if (!isNaN(num)) {
333
+ coerced[key] = num;
334
+ }
335
+ }
336
+
337
+ // Coerce boolean
338
+ if (def.type === 'boolean' && typeof coerced[key] === 'string') {
339
+ coerced[key] = coerced[key] === 'true' || coerced[key] === '1';
340
+ }
341
+ }
342
+
343
+ return coerced;
344
+ }
345
+
346
+ /**
347
+ * Execute MCP tool call
348
+ */
349
+ async function executeMcpTool(toolName, toolArgs) {
350
+ // Find which server has this tool
351
+ for (const [serverName, { client, tools }] of Object.entries(mcpClients)) {
352
+ const tool = tools.find(t => t.name === toolName);
353
+
354
+ if (tool) {
355
+ debugLog(`[MCP] Calling tool ${toolName} on server ${serverName}`);
356
+
357
+ try {
358
+ // Coerce parameters to match schema types
359
+ const coercedArgs = coerceMcpParams(tool, toolArgs);
360
+
361
+ const result = await client.callTool(toolName, coercedArgs);
362
+
363
+ // MCP returns { content: [{ type: 'text', text: '...' }] }
364
+ if (result.content && result.content.length > 0) {
365
+ // Extract all text content and join with newlines
366
+ const textContents = result.content
367
+ .filter(c => c.type === 'text')
368
+ .map(c => c.text);
369
+
370
+ if (textContents.length > 0) {
371
+ let output = textContents.join('\n');
372
+
373
+ // Truncate MCP result
374
+ const maxChars = TB.load().toolMaxChars;
375
+ if (output.length > maxChars) {
376
+ output = output.slice(0, maxChars) + '... (truncated)';
377
+ }
378
+
379
+ return {
380
+ success: true,
381
+ output: output
382
+ };
383
+ }
384
+ }
385
+
386
+ // Fallback: return entire result as JSON
387
+ let fallbackOutput = JSON.stringify(result, null, 2);
388
+
389
+ // Truncate fallback output too
390
+ const maxChars = TB.load().toolMaxChars;
391
+ if (fallbackOutput.length > maxChars) {
392
+ fallbackOutput = fallbackOutput.slice(0, maxChars) + '... (truncated)';
393
+ }
394
+
395
+ return {
396
+ success: true,
397
+ output: fallbackOutput
398
+ };
399
+
400
+ } catch (err) {
401
+ return {
402
+ success: false,
403
+ error: `MCP tool error: ${err.message}`
404
+ };
405
+ }
406
+ }
407
+ }
408
+
409
+ return {
410
+ success: false,
411
+ error: `MCP tool not found: ${toolName}`
412
+ };
413
+ }
414
+
415
+ /**
416
+ * Encode tool result for safe transmission
417
+ * Works for both MCP and local tools
418
+ */
419
+ function encodeToolResult(toolResult) {
420
+ let content;
421
+
422
+ // Handle different result formats
423
+ if (typeof toolResult === 'string') {
424
+ content = toolResult;
425
+ } else if (toolResult.output) {
426
+ content = toolResult.output;
427
+ } else if (toolResult.success !== undefined) {
428
+ // Handle { success: true/false, output/error: ... } format
429
+ content = toolResult.success ? (toolResult.output || JSON.stringify(toolResult)) : (toolResult.error || 'Unknown error');
430
+ } else {
431
+ content = JSON.stringify(toolResult);
432
+ }
433
+
434
+ // Base64 encode
435
+ const encoded = Buffer.from(content).toString('base64');
436
+ return `[BASE64_ENCODED_RESULT]: ${encoded}`;
437
+ }
438
+
439
+ /**
440
+ * Check if debug mode is enabled
441
+ */
442
+ function isDebugEnabled() {
443
+ const config = getConfig();
444
+ return config.debug === true || config.debug === 'true';
445
+ }
446
+
447
+ /**
448
+ * Debug log (only if debug mode enabled)
449
+ */
450
+ function debugLog(...args) {
451
+ if (isDebugEnabled()) {
452
+ console.log(...args);
453
+ }
454
+ }
455
+
456
+ /**
457
+ * Get provider configuration from config
458
+ */
459
+ function getProviderConfig() {
460
+ const config = getConfig();
461
+
462
+ // Universal provider config (v2.1.0+)
463
+ if (config.providerUrl && config.providerApiKey) {
464
+ return {
465
+ url: config.providerUrl,
466
+ apiKey: config.providerApiKey,
467
+ model: config.providerModel || 'llama-3.3-70b-versatile',
468
+ isAnthropic: config.providerUrl.includes('anthropic.com')
469
+ };
470
+ }
471
+
472
+ // Legacy Groq config (v2.0.x)
473
+ if (config.groqApiKey) {
474
+ return {
475
+ url: 'https://api.groq.com/openai/v1',
476
+ apiKey: config.groqApiKey,
477
+ model: config.groqModel || 'llama-3.3-70b-versatile',
478
+ isAnthropic: false
479
+ };
480
+ }
481
+
482
+ return null;
483
+ }
484
+
485
+ /**
486
+ * Format tool definitions for OpenAI-compatible APIs
487
+ */
488
+ function formatToolsForOpenAI() {
489
+ const config = getConfig();
490
+ const localTools = getToolDefinitions();
491
+
492
+ // Only add MCP tools if enabled
493
+ let allTools = [...localTools];
494
+ if (config.mcpEnabled !== false) {
495
+ const mcpTools = getMcpToolsForAI().map(minimizeMcpTool);
496
+ const normalizedMcpTools = mcpTools.map(tool => normalizeMcpToolSchema(tool));
497
+ allTools = [...allTools, ...normalizedMcpTools];
498
+ }
499
+
500
+ return allTools.map(tool => ({
501
+ type: 'function',
502
+ function: {
503
+ name: tool.name,
504
+ description: tool.description,
505
+ parameters: tool.inputSchema || tool.input_schema || { type: 'object', properties: {} }
506
+ }
507
+ }));
508
+ }
509
+
510
+ /**
511
+ * Format tool definitions for Anthropic API
512
+ */
513
+ function formatToolsForAnthropic() {
514
+ const config = getConfig();
515
+ const localTools = getToolDefinitions();
516
+
517
+ // Only add MCP tools if enabled
518
+ let allTools = [...localTools];
519
+ if (config.mcpEnabled !== false) {
520
+ const mcpTools = getMcpToolsForAI().map(minimizeMcpTool);
521
+ const normalizedMcpTools = mcpTools.map(tool => normalizeMcpToolSchema(tool));
522
+ allTools = [...allTools, ...normalizedMcpTools];
523
+ }
524
+
525
+ return allTools.map(tool => ({
526
+ name: tool.name,
527
+ description: tool.description,
528
+ input_schema: tool.inputSchema || tool.input_schema || { type: 'object', properties: {} }
529
+ }));
530
+ }
531
+
532
+ /**
533
+ * Send message to OpenAI-compatible provider (Groq, OpenAI, Together, etc.)
534
+ */
535
+ async function sendMessageOpenAICompatible(providerConfig, messages, tools) {
536
+ const baseUrl = providerConfig.url.replace(/\/+$/, '');
537
+ // MiniMax özel endpoint tespiti
538
+ const isMiniMax = baseUrl.includes('minimax.io') || baseUrl.includes('minimaxi.com') || baseUrl.includes('minimax.cn');
539
+ const endpoint = isMiniMax
540
+ ? `${baseUrl}/v1/text/chatcompletion_v2`
541
+ : `${baseUrl}/chat/completions`;
542
+ const requestBody = {
543
+ model: providerConfig.model,
544
+ messages: messages,
545
+ temperature: 0.7,
546
+ max_tokens: 2048,
547
+ };
548
+
549
+ // NatureCo için tool calling desteklenmiyor
550
+ if (!providerConfig.url.includes('api.natureco.me')) {
551
+ if (tools && tools.length > 0) {
552
+ requestBody.tools = tools;
553
+ requestBody.tool_choice = 'auto';
554
+ }
555
+ }
556
+
557
+ const response = await fetch(endpoint, {
558
+ method: 'POST',
559
+ headers: {
560
+ 'Authorization': `Bearer ${providerConfig.apiKey}`,
561
+ 'Content-Type': 'application/json',
562
+ },
563
+ body: JSON.stringify(requestBody),
564
+ });
565
+
566
+ if (!response.ok) {
567
+ const errorText = await response.text();
568
+ throw new Error(`Provider API error: ${response.status} - ${errorText}`);
569
+ }
570
+
571
+ const data = await response.json();
572
+ const content = data.choices?.[0]?.message?.content
573
+ || data.choices?.[0]?.text
574
+ || data.response
575
+ || data.content
576
+ || '';
577
+ return {
578
+ role: 'assistant',
579
+ content,
580
+ tool_calls: data.choices?.[0]?.message?.tool_calls || undefined,
581
+ usage: data.usage || undefined,
582
+ };
583
+ }
584
+
585
+ /**
586
+ * Send message to Anthropic API
587
+ */
588
+ async function sendMessageAnthropic(providerConfig, messages, tools) {
589
+ const endpoint = `${providerConfig.url}/v1/messages`;
590
+
591
+ // Anthropic requires system message separate
592
+ const systemMessage = messages.find(m => m.role === 'system');
593
+ const userMessages = messages.filter(m => m.role !== 'system');
594
+
595
+ const response = await fetch(endpoint, {
596
+ method: 'POST',
597
+ headers: {
598
+ 'x-api-key': providerConfig.apiKey,
599
+ 'anthropic-version': '2023-06-01',
600
+ 'Content-Type': 'application/json',
601
+ },
602
+ body: JSON.stringify({
603
+ model: providerConfig.model,
604
+ max_tokens: 2000,
605
+ system: systemMessage?.content || '',
606
+ messages: userMessages,
607
+ tools: tools,
608
+ }),
609
+ });
610
+
611
+ if (!response.ok) {
612
+ const errorText = await response.text();
613
+ throw new Error(`Anthropic API error: ${response.status} - ${errorText}`);
614
+ }
615
+
616
+ const data = await response.json();
617
+
618
+ // Convert Anthropic response to OpenAI format
619
+ const content = data.content.find(c => c.type === 'text')?.text || '';
620
+ const toolCalls = data.content
621
+ .filter(c => c.type === 'tool_use')
622
+ .map(c => ({
623
+ id: c.id,
624
+ type: 'function',
625
+ function: {
626
+ name: c.name,
627
+ arguments: JSON.stringify(c.input)
628
+ }
629
+ }));
630
+
631
+ return {
632
+ role: 'assistant',
633
+ content: content,
634
+ tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
635
+ usage: data.usage || undefined,
636
+ };
637
+ }
638
+
639
+ /**
640
+ * Send message with tool support (universal)
641
+ */
642
+ async function sendMessageToProvider(apiKey, message, conversationId = null, systemPrompt = null, options = {}) {
643
+ const providerConfig = getProviderConfig();
644
+
645
+ if (!providerConfig) {
646
+ throw new Error(
647
+ 'Provider not configured. Set with:\n' +
648
+ ' natureco config set providerUrl https://api.groq.com/openai/v1\n' +
649
+ ' natureco config set providerApiKey gsk_xxx\n' +
650
+ ' natureco config set providerModel llama-3.3-70b-versatile'
651
+ );
652
+ }
653
+
654
+ // Start MCP servers if not already started
655
+ if (Object.keys(mcpClients).length === 0) {
656
+ await startMcpServers();
657
+ }
658
+
659
+ // Get or create conversation history (load from disk)
660
+ // Use consistent ID based on provider config instead of timestamp
661
+ const convId = conversationId || generateDefaultConvId();
662
+ const history = loadConversation(convId);
663
+
664
+ // Augment system prompt with project AGENTS.md instructions
665
+ const agentsMd = require('./agents-md');
666
+ const augmentedPrompt = agentsMd.injectIntoPrompt(systemPrompt || '', options?.cwd || process.cwd());
667
+
668
+ // Build messages
669
+ let messages = [];
670
+ if (augmentedPrompt) {
671
+ messages.push({ role: 'system', content: augmentedPrompt });
672
+ }
673
+ messages.push(...history);
674
+ messages.push({ role: 'user', content: message });
675
+
676
+ // Get tool definitions (local + MCP) — skip if noTools flag set (chat mode)
677
+ const tools = options.noTools
678
+ ? []
679
+ : (providerConfig.isAnthropic ? formatToolsForAnthropic() : formatToolsForOpenAI());
680
+
681
+ debugLog('\n[Provider] Sending request...');
682
+ debugLog('[Provider] URL:', providerConfig.url);
683
+ debugLog('[Provider] Model:', providerConfig.model);
684
+ debugLog('[Provider] Type:', providerConfig.isAnthropic ? 'Anthropic' : 'OpenAI-compatible');
685
+ debugLog('[Provider] Messages:', messages.length);
686
+ debugLog('[Provider] Tools:', tools.length, `(${Object.keys(mcpClients).length} MCP servers)`);
687
+
688
+ // Tool execution loop (max 10 iterations)
689
+ let iteration = 0;
690
+ const maxIterations = 10;
691
+ let finalResponse = null;
692
+ const stream = (options.stream ?? options.noStream === undefined) !== false &&
693
+ !providerConfig.url.includes('api.natureco.me');
694
+
695
+ while (iteration < maxIterations) {
696
+ iteration++;
697
+ debugLog(`\n[Provider] Iteration ${iteration}/${maxIterations}`);
698
+
699
+ let assistantMessage;
700
+
701
+ if (stream) {
702
+ const result = await streamProviderCompletion(providerConfig, messages, tools);
703
+ if (result.type === 'text') {
704
+ messages.push({ role: 'assistant', content: result.content });
705
+ finalResponse = result.content;
706
+ break;
707
+ }
708
+ assistantMessage = result.message;
709
+ } else {
710
+ assistantMessage = providerConfig.isAnthropic
711
+ ? await sendMessageAnthropic(providerConfig, messages, tools)
712
+ : await sendMessageOpenAICompatible(providerConfig, messages, tools);
713
+ }
714
+
715
+ if (!assistantMessage) {
716
+ return {
717
+ reply: 'No response from provider',
718
+ conversation_id: convId,
719
+ message_id: `msg_${Date.now()}`,
720
+ success: false
721
+ };
722
+ }
723
+
724
+ // Track token usage if available
725
+ if (assistantMessage.usage) {
726
+ TB.trackUsage(convId, {
727
+ input: assistantMessage.usage.prompt_tokens || assistantMessage.usage.input_tokens || 0,
728
+ output: assistantMessage.usage.completion_tokens || assistantMessage.usage.output_tokens || 0
729
+ });
730
+ }
731
+
732
+ debugLog('[Provider] Response type:', assistantMessage?.tool_calls ? 'tool_calls' : 'text');
733
+
734
+ // Add assistant message to history
735
+ messages.push(assistantMessage);
736
+
737
+ // Check for tool calls
738
+ const hasToolCalls = assistantMessage?.tool_calls?.length > 0;
739
+ if (hasToolCalls) {
740
+ debugLog(`[Provider] Tool calls: ${assistantMessage.tool_calls.length}`);
741
+
742
+ // Separate local and MCP tool calls
743
+ const toolCalls = assistantMessage.tool_calls.map(tc => ({
744
+ id: tc.id,
745
+ name: tc.function.name,
746
+ input: JSON.parse(tc.function.arguments)
747
+ }));
748
+
749
+ const toolResults = [];
750
+ const SPINNER_FRAMES = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
751
+
752
+ for (const toolCall of toolCalls) {
753
+ // Spinner başlat
754
+ let frameIdx = 0;
755
+ const inputPreview = JSON.stringify(toolCall.input).slice(0, 50);
756
+ const spinner = setInterval(() => {
757
+ process.stdout.write(`\r ${chalk.cyan(SPINNER_FRAMES[frameIdx++ % SPINNER_FRAMES.length])} ${chalk.gray(toolCall.name + ' — ' + inputPreview)}`);
758
+ }, 80);
759
+
760
+ // Check if this is an MCP tool
761
+ const mcpTools = getMcpTools();
762
+ const isMcpTool = mcpTools.find(t => t.name === toolCall.name);
763
+ let result;
764
+
765
+ if (isMcpTool) {
766
+ debugLog(`[MCP] Executing tool: ${toolCall.name}`);
767
+ result = await executeMcpTool(toolCall.name, toolCall.input);
768
+ toolResults.push({ id: toolCall.id, name: toolCall.name, result });
769
+ } else {
770
+ debugLog(`[Local] Executing tool: ${toolCall.name}`);
771
+ const localResults = await executeToolCalls([toolCall]);
772
+ toolResults.push(...localResults);
773
+ result = localResults[0]?.result;
774
+ }
775
+
776
+ // Spinner durdur, sonucu göster
777
+ clearInterval(spinner);
778
+ const success = result?.success !== false;
779
+ process.stdout.write(`\r ${success ? chalk.green('✓') : chalk.red('✗')} ${chalk.cyan(toolCall.name)} ${chalk.gray('— ' + inputPreview)}\n`);
780
+ }
781
+
782
+ // Add tool results to messages (base64 encoded for safety)
783
+ for (const result of toolResults) {
784
+ // Encode tool result (works for both MCP and local tools)
785
+ const encodedContent = encodeToolResult(result.result);
786
+
787
+ messages.push({
788
+ role: 'tool',
789
+ tool_call_id: result.id,
790
+ name: result.name,
791
+ content: encodedContent
792
+ });
793
+ }
794
+
795
+ // Continue loop to get final response
796
+ continue;
797
+ }
798
+
799
+ // No tool calls, we have final response
800
+ finalResponse = assistantMessage?.content;
801
+ break;
802
+ }
803
+
804
+ if (iteration >= maxIterations) {
805
+ debugLog('\n[Provider] Max iterations reached');
806
+ finalResponse = finalResponse || 'Max tool execution iterations reached.';
807
+ }
808
+
809
+ // Apply token budget trimming
810
+ messages = TB.trimMessages(messages);
811
+
812
+ // Save to conversation history (only user and final assistant message)
813
+ history.push({ role: 'user', content: message });
814
+ history.push({ role: 'assistant', content: finalResponse });
815
+
816
+ // Save to disk (automatically keeps last 20 messages)
817
+ saveConversation(convId, history);
818
+
819
+ return {
820
+ reply: finalResponse,
821
+ conversation_id: convId,
822
+ message_id: `msg_${Date.now()}`,
823
+ success: true
824
+ };
825
+ }
826
+
827
+ /**
828
+ * Clear conversation history
829
+ */
830
+ function clearConversation(conversationId) {
831
+ if (conversationId) {
832
+ // Delete from disk
833
+ const file = path.join(CONV_DIR, `${conversationId.replace(/[^a-z0-9]/gi, '_')}.json`);
834
+ try {
835
+ if (fs.existsSync(file)) {
836
+ fs.unlinkSync(file);
837
+ }
838
+ } catch (e) {
839
+ // Silently fail
840
+ }
841
+ // Also clear from memory (legacy)
842
+ conversationHistory.delete(conversationId);
843
+ } else {
844
+ // Clear all conversations from disk
845
+ try {
846
+ if (fs.existsSync(CONV_DIR)) {
847
+ const files = fs.readdirSync(CONV_DIR);
848
+ files.forEach(file => {
849
+ fs.unlinkSync(path.join(CONV_DIR, file));
850
+ });
851
+ }
852
+ } catch (e) {
853
+ // Silently fail
854
+ }
855
+ // Also clear from memory (legacy)
856
+ conversationHistory.clear();
857
+ }
858
+ }
859
+
860
+ /**
861
+ * Legacy function for compatibility
862
+ * Now supports custom system prompts for different platforms (terminal, WhatsApp, etc.)
863
+ * @param {string} chatSystemPrompt - System prompt from chat.js (skills + memory + agents)
864
+ */
865
+ async function sendMessage(apiKey, botId, message, conversationId = null, chatSystemPrompt = '', options = {}) {
866
+ // Handle legacy 6th param (toolDefinitions array was passed)
867
+ if (Array.isArray(options)) options = {};
868
+ const providerConfig = getProviderConfig();
869
+
870
+ // Get user's home directory
871
+ const homeDir = os.homedir();
872
+
873
+ // Load memory to get botName
874
+ const { loadMemory } = require('./memory');
875
+ const mem = loadMemory(botId);
876
+
877
+ // Get config to check MCP status
878
+ const config = getConfig();
879
+
880
+ // NatureCo minimal system prompt, skip tool descriptions/MCP
881
+ if (providerConfig && providerConfig.url.includes('api.natureco.me')) {
882
+ const prompt = chatSystemPrompt || 'Sen yardımcı bir AI asistansın.';
883
+ return sendMessageToProvider(apiKey, message, conversationId, prompt, options);
884
+ }
885
+
886
+ // Minimal base prompt (~200 token)
887
+ const toolDefs = getToolDefinitions();
888
+ const toolsDesc = toolDefs.map(t => t.name).join(', ');
889
+ let systemPrompt = `Assistant. Tools: ${toolsDesc}. Home: ${homeDir}.`;
890
+
891
+ // Skill prompts only, max 500 chars
892
+ if (chatSystemPrompt) {
893
+ systemPrompt += '\n' + chatSystemPrompt.slice(0, TB.load().systemPromptMaxChars);
894
+ }
895
+
896
+ return sendMessageToProvider(apiKey, message, conversationId, systemPrompt, options);
897
+ }
898
+
899
+ /**
900
+ * Validate API key against NatureCo backend
901
+ * Returns { valid, error, user }
902
+ */
903
+ async function validateApiKey(apiKey) {
904
+ const result = { valid: false, error: null, user: null };
905
+ if (!apiKey) {
906
+ result.error = 'API key boş olamaz';
907
+ return result;
908
+ }
909
+ try {
910
+ const res = await fetch('https://api.natureco.me/api/v1/user/me', {
911
+ headers: {
912
+ 'Authorization': `Bearer ${apiKey}`,
913
+ 'Content-Type': 'application/json',
914
+ },
915
+ });
916
+ if (res.ok) {
917
+ const body = await res.json();
918
+ result.valid = true;
919
+ result.user = body.user || body.data || body;
920
+ return result;
921
+ }
922
+ let errorBody = '';
923
+ try { errorBody = await res.text(); } catch {}
924
+ result.error = `API doğrulama hatası (${res.status}): ${errorBody || 'Geçersiz API key'}`;
925
+ return result;
926
+ } catch (e) {
927
+ result.error = `Bağlantı hatası: ${e.message}`;
928
+ return result;
929
+ }
930
+ }
931
+
932
+ /**
933
+ * Get bots (not used in v2.x, kept for compatibility)
934
+ */
935
+ async function getBots(apiKey) {
936
+ const config = getConfig();
937
+ const providerConfig = getProviderConfig();
938
+
939
+ // NatureCo provider — gerçek bot listesini API'den çek
940
+ if (config.providerUrl && config.providerUrl.includes('natureco.me')) {
941
+ try {
942
+ const res = await fetch('https://api.natureco.me/api/v1/bots', {
943
+ headers: {
944
+ 'Authorization': `Bearer ${config.providerApiKey || apiKey}`,
945
+ 'Content-Type': 'application/json',
946
+ },
947
+ });
948
+ if (res.ok) {
949
+ const data = await res.json();
950
+ const bots = Array.isArray(data) ? data : (data.bots || data.data || []);
951
+ if (bots.length > 0) {
952
+ return {
953
+ bots: bots.map(b => ({
954
+ id: b.id,
955
+ name: b.name,
956
+ ai_provider: b.ai_provider || 'natureco',
957
+ model: b.model || 'natureco-default',
958
+ system_prompt: b.system_prompt || '',
959
+ }))
960
+ };
961
+ }
962
+ }
963
+ } catch (e) {
964
+ debugLog('[getBots] NatureCo API error:', e.message);
965
+ return { bots: [], error: e.message };
966
+ }
967
+ }
968
+
969
+ // Diğer provider'lar — universal provider döndür
970
+ const providerName = providerConfig?.isAnthropic ? 'Anthropic' : 'OpenAI-compatible';
971
+ const botName = config.botName || `Universal Provider (${providerName})`;
972
+ return {
973
+ bots: [
974
+ {
975
+ id: 'universal-provider',
976
+ name: botName,
977
+ ai_provider: providerName,
978
+ model: providerConfig?.model || 'unknown'
979
+ }
980
+ ]
981
+ };
982
+ }
983
+
984
+ // ── Streaming Support ────────────────────────────────────────────────────────────
985
+
986
+ async function streamProviderCompletion(providerConfig, messages, tools) {
987
+ if (providerConfig.isAnthropic) {
988
+ return streamAnthropicCompletion(providerConfig, messages);
989
+ }
990
+ return streamOpenAICompletion(providerConfig, messages, tools);
991
+ }
992
+
993
+ async function streamOpenAICompletion(providerConfig, messages, tools) {
994
+ const baseUrl = providerConfig.url.replace(/\/+$/, '');
995
+ // MiniMax özel endpoint tespiti (streaming için de aynı)
996
+ const isMiniMax = baseUrl.includes('minimax.io') || baseUrl.includes('minimaxi.com') || baseUrl.includes('minimax.cn');
997
+ const endpoint = isMiniMax
998
+ ? `${baseUrl}/v1/text/chatcompletion_v2`
999
+ : `${baseUrl}/chat/completions`;
1000
+
1001
+ const requestBody = {
1002
+ model: providerConfig.model,
1003
+ messages,
1004
+ temperature: 0.7,
1005
+ max_tokens: 2000,
1006
+ stream: true,
1007
+ };
1008
+ if (tools && tools.length > 0) {
1009
+ requestBody.tools = tools;
1010
+ requestBody.tool_choice = 'auto';
1011
+ }
1012
+
1013
+ const response = await fetch(endpoint, {
1014
+ method: 'POST',
1015
+ headers: {
1016
+ 'Authorization': `Bearer ${providerConfig.apiKey}`,
1017
+ 'Content-Type': 'application/json',
1018
+ },
1019
+ body: JSON.stringify(requestBody),
1020
+ });
1021
+
1022
+ if (!response.ok) {
1023
+ throw new Error(`Provider API error: ${response.status} - ${await response.text()}`);
1024
+ }
1025
+
1026
+ const reader = response.body.getReader();
1027
+ const decoder = new TextDecoder();
1028
+ let fullText = '';
1029
+ const toolCalls = [];
1030
+ let hasToolCalls = false;
1031
+
1032
+ while (true) {
1033
+ const { done, value } = await reader.read();
1034
+ if (done) break;
1035
+
1036
+ const chunk = decoder.decode(value, { stream: true });
1037
+ const lines = chunk.split('\n').filter(l => l.startsWith('data: '));
1038
+
1039
+ for (const line of lines) {
1040
+ const data = line.slice(6).trim();
1041
+ if (data === '[DONE]') continue;
1042
+ try {
1043
+ const parsed = JSON.parse(data);
1044
+ const delta = parsed.choices?.[0]?.delta;
1045
+ if (!delta) continue;
1046
+
1047
+ if (delta.tool_calls) {
1048
+ hasToolCalls = true;
1049
+ for (const tc of delta.tool_calls) {
1050
+ const idx = tc.index;
1051
+ if (!toolCalls[idx]) {
1052
+ toolCalls[idx] = { id: tc.id || '', type: 'function', function: { name: '', arguments: '' } };
1053
+ }
1054
+ if (tc.id) toolCalls[idx].id = tc.id;
1055
+ if (tc.function?.name) toolCalls[idx].function.name += tc.function.name;
1056
+ if (tc.function?.arguments) toolCalls[idx].function.arguments += tc.function.arguments;
1057
+ }
1058
+ }
1059
+
1060
+ const token = delta.content || '';
1061
+ if (token) {
1062
+ if (!hasToolCalls) process.stdout.write(token);
1063
+ fullText += token;
1064
+ }
1065
+ } catch {}
1066
+ }
1067
+ }
1068
+
1069
+ if (hasToolCalls) {
1070
+ process.stdout.write('\n');
1071
+ return {
1072
+ type: 'tool_calls',
1073
+ message: {
1074
+ role: 'assistant',
1075
+ content: fullText || null,
1076
+ tool_calls: toolCalls.map(tc => ({
1077
+ id: tc.id,
1078
+ type: tc.type,
1079
+ function: { name: tc.function.name, arguments: tc.function.arguments }
1080
+ }))
1081
+ }
1082
+ };
1083
+ }
1084
+
1085
+ process.stdout.write('\n');
1086
+ return { type: 'text', content: fullText };
1087
+ }
1088
+
1089
+ async function streamAnthropicCompletion(providerConfig, messages) {
1090
+ const endpoint = `${providerConfig.url}/v1/messages`;
1091
+
1092
+ const systemMessage = messages.find(m => m.role === 'system');
1093
+ const userMessages = messages.filter(m => m.role !== 'system');
1094
+
1095
+ const response = await fetch(endpoint, {
1096
+ method: 'POST',
1097
+ headers: {
1098
+ 'x-api-key': providerConfig.apiKey,
1099
+ 'anthropic-version': '2023-06-01',
1100
+ 'Content-Type': 'application/json',
1101
+ },
1102
+ body: JSON.stringify({
1103
+ model: providerConfig.model,
1104
+ max_tokens: 2000,
1105
+ system: systemMessage?.content || '',
1106
+ messages: userMessages,
1107
+ stream: true,
1108
+ }),
1109
+ });
1110
+
1111
+ if (!response.ok) {
1112
+ throw new Error(`Anthropic API error: ${response.status} - ${await response.text()}`);
1113
+ }
1114
+
1115
+ const reader = response.body.getReader();
1116
+ const decoder = new TextDecoder();
1117
+ let fullText = '';
1118
+
1119
+ while (true) {
1120
+ const { done, value } = await reader.read();
1121
+ if (done) break;
1122
+
1123
+ const chunk = decoder.decode(value, { stream: true });
1124
+ const lines = chunk.split('\n').filter(l => l.startsWith('data: '));
1125
+
1126
+ for (const line of lines) {
1127
+ const data = line.slice(6).trim();
1128
+ if (data === '[DONE]') continue;
1129
+ try {
1130
+ const parsed = JSON.parse(data);
1131
+ const token = parsed.delta?.text || '';
1132
+ if (token) {
1133
+ process.stdout.write(token);
1134
+ fullText += token;
1135
+ }
1136
+ } catch {}
1137
+ }
1138
+ }
1139
+
1140
+ process.stdout.write('\n');
1141
+ return { type: 'text', content: fullText };
1142
+ }
1143
+
1144
+ module.exports = {
1145
+ sendMessage,
1146
+ sendMessageToProvider,
1147
+ validateApiKey,
1148
+ getBots,
1149
+ clearConversation,
1150
+ getProviderConfig,
1151
+ startMcpServers,
1152
+ stopMcpServers,
1153
+ getMcpTools,
1154
+ streamProviderCompletion,
1155
+ streamOpenAICompletion,
1156
+ streamAnthropicCompletion,
1157
+ _sendMessage: sendMessage,
1158
+ };