diff --git a/.env.example b/.env.example index 44eb01b..b44266f 100644 --- a/.env.example +++ b/.env.example @@ -31,7 +31,7 @@ REFRESH_INTERVAL_MINUTES=15 # === LLM Layer (optional) === # Enables AI-enhanced trade ideas and breaking news Telegram alerts. -# Provider options: anthropic | openai | gemini | codex | openrouter | minimax +# Provider options: anthropic | openai | gemini | codex | openrouter | minimax | mistral LLM_PROVIDER= # Not needed for codex (uses ~/.codex/auth.json) LLM_API_KEY= diff --git a/crucix.config.mjs b/crucix.config.mjs index da25ca1..c9e7235 100644 --- a/crucix.config.mjs +++ b/crucix.config.mjs @@ -7,7 +7,7 @@ export default { refreshIntervalMinutes: parseInt(process.env.REFRESH_INTERVAL_MINUTES) || 15, llm: { - provider: process.env.LLM_PROVIDER || null, // anthropic | openai | gemini | codex | openrouter | minimax + provider: process.env.LLM_PROVIDER || null, // anthropic | openai | gemini | codex | openrouter | minimax | mistral apiKey: process.env.LLM_API_KEY || null, model: process.env.LLM_MODEL || null, }, diff --git a/lib/llm/index.mjs b/lib/llm/index.mjs index 7320a66..b2d16ee 100644 --- a/lib/llm/index.mjs +++ b/lib/llm/index.mjs @@ -6,6 +6,7 @@ import { OpenRouterProvider } from './openrouter.mjs'; import { GeminiProvider } from './gemini.mjs'; import { CodexProvider } from './codex.mjs'; import { MiniMaxProvider } from './minimax.mjs'; +import { MistralProvider } from './mistral.mjs'; export { LLMProvider } from './provider.mjs'; export { AnthropicProvider } from './anthropic.mjs'; @@ -14,6 +15,7 @@ export { OpenRouterProvider } from './openrouter.mjs'; export { GeminiProvider } from './gemini.mjs'; export { CodexProvider } from './codex.mjs'; export { MiniMaxProvider } from './minimax.mjs'; +export { MistralProvider } from './mistral.mjs'; /** * Create an LLM provider based on config. @@ -38,6 +40,8 @@ export function createLLMProvider(llmConfig) { return new CodexProvider({ model }); case 'minimax': return new MiniMaxProvider({ apiKey, model }); + case 'mistral': + return new MistralProvider({ apiKey, model }); default: console.warn(`[LLM] Unknown provider "${provider}". LLM features disabled.`); return null; diff --git a/lib/llm/mistral.mjs b/lib/llm/mistral.mjs new file mode 100644 index 0000000..fa3e525 --- /dev/null +++ b/lib/llm/mistral.mjs @@ -0,0 +1,51 @@ +// Mistral AI Provider — raw fetch, no SDK +// Uses Mistral's OpenAI-compatible Chat Completions API + +import { LLMProvider } from './provider.mjs'; + +export class MistralProvider extends LLMProvider { + constructor(config) { + super(config); + this.name = 'mistral'; + this.apiKey = config.apiKey; + this.model = config.model || 'mistral-medium'; + } + + get isConfigured() { return !!this.apiKey; } + + async complete(systemPrompt, userMessage, opts = {}) { + const res = await fetch('https://api.mistral.ai/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiKey}`, + }, + body: JSON.stringify({ + model: this.model, + max_tokens: opts.maxTokens || 4096, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userMessage }, + ], + }), + signal: AbortSignal.timeout(opts.timeout || 60000), + }); + + if (!res.ok) { + const err = await res.text().catch(() => ''); + throw new Error(`Mistral API ${res.status}: ${err.substring(0, 200)}`); + } + + const data = await res.json(); + const text = data.choices?.[0]?.message?.content || ''; + + return { + text, + usage: { + inputTokens: data.usage?.prompt_tokens || 0, + outputTokens: data.usage?.completion_tokens || 0, + }, + model: data.model || this.model, + }; + } +} diff --git a/test/llm-mistral-integration.test.mjs b/test/llm-mistral-integration.test.mjs new file mode 100644 index 0000000..c0f42c6 --- /dev/null +++ b/test/llm-mistral-integration.test.mjs @@ -0,0 +1,144 @@ +// Mistral provider — unit tests +// Uses Node.js built-in test runner (node:test) — no extra dependencies + +import { describe, it, mock, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { MistralProvider } from '../lib/llm/mistral.mjs'; +import { createLLMProvider } from '../lib/llm/index.mjs'; + +// ─── Unit Tests ─── + +describe('MistralProvider', () => { + it('should set defaults correctly', () => { + const provider = new MistralProvider({ apiKey: 'sk-test' }); + assert.equal(provider.name, 'mistral'); + assert.equal(provider.model, 'mistral-medium'); + assert.equal(provider.isConfigured, true); + }); + + it('should accept custom model', () => { + const provider = new MistralProvider({ apiKey: 'sk-test', model: 'mistral-large-latest' }); + assert.equal(provider.model, 'mistral-large-latest'); + }); + + it('should report not configured without API key', () => { + const provider = new MistralProvider({}); + assert.equal(provider.isConfigured, false); + }); + + it('should throw on API error', async () => { + const provider = new MistralProvider({ apiKey: 'sk-test' }); + const originalFetch = globalThis.fetch; + globalThis.fetch = mock.fn(() => + Promise.resolve({ ok: false, status: 401, text: () => Promise.resolve('Unauthorized') }) + ); + try { + await assert.rejects( + () => provider.complete('system', 'user'), + (err) => { + assert.match(err.message, /Mistral API 401/); + return true; + } + ); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('should parse successful response', async () => { + const provider = new MistralProvider({ apiKey: 'sk-test' }); + const mockResponse = { + choices: [{ message: { content: 'Hello from Mistral' } }], + usage: { prompt_tokens: 10, completion_tokens: 5 }, + model: 'mistral-medium', + }; + const originalFetch = globalThis.fetch; + globalThis.fetch = mock.fn(() => + Promise.resolve({ ok: true, json: () => Promise.resolve(mockResponse) }) + ); + try { + const result = await provider.complete('You are helpful.', 'Say hello'); + assert.equal(result.text, 'Hello from Mistral'); + assert.equal(result.usage.inputTokens, 10); + assert.equal(result.usage.outputTokens, 5); + assert.equal(result.model, 'mistral-medium'); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('should send correct request format', async () => { + const provider = new MistralProvider({ apiKey: 'sk-test-key', model: 'mistral-medium' }); + let capturedUrl, capturedOpts; + const originalFetch = globalThis.fetch; + globalThis.fetch = mock.fn((url, opts) => { + capturedUrl = url; + capturedOpts = opts; + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + choices: [{ message: { content: 'ok' } }], + usage: { prompt_tokens: 1, completion_tokens: 1 }, + model: 'mistral-medium', + }), + }); + }); + try { + await provider.complete('system prompt', 'user message', { maxTokens: 2048 }); + assert.equal(capturedUrl, 'https://api.mistral.ai/v1/chat/completions'); + assert.equal(capturedOpts.method, 'POST'); + const headers = capturedOpts.headers; + assert.equal(headers['Content-Type'], 'application/json'); + assert.equal(headers['Authorization'], 'Bearer sk-test-key'); + const body = JSON.parse(capturedOpts.body); + assert.equal(body.model, 'mistral-medium'); + assert.equal(body.max_tokens, 2048); + assert.equal(body.messages[0].role, 'system'); + assert.equal(body.messages[0].content, 'system prompt'); + assert.equal(body.messages[1].role, 'user'); + assert.equal(body.messages[1].content, 'user message'); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('should handle empty response gracefully', async () => { + const provider = new MistralProvider({ apiKey: 'sk-test' }); + const originalFetch = globalThis.fetch; + globalThis.fetch = mock.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ choices: [], usage: {} }), + }) + ); + try { + const result = await provider.complete('sys', 'user'); + assert.equal(result.text, ''); + assert.equal(result.usage.inputTokens, 0); + assert.equal(result.usage.outputTokens, 0); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); + +// ─── Factory Tests ─── + +describe('createLLMProvider — mistral', () => { + it('should create MistralProvider for provider=mistral', () => { + const provider = createLLMProvider({ provider: 'mistral', apiKey: 'sk-test', model: null }); + assert.ok(provider instanceof MistralProvider); + assert.equal(provider.name, 'mistral'); + assert.equal(provider.isConfigured, true); + }); + + it('should be case-insensitive', () => { + const provider = createLLMProvider({ provider: 'Mistral', apiKey: 'sk-test', model: null }); + assert.ok(provider instanceof MistralProvider); + }); + + it('should return null for empty provider', () => { + const provider = createLLMProvider({ provider: null, apiKey: 'sk-test', model: null }); + assert.equal(provider, null); + }); +}); diff --git a/test/llm-mistral.test.mjs b/test/llm-mistral.test.mjs new file mode 100644 index 0000000..2dc1d8d --- /dev/null +++ b/test/llm-mistral.test.mjs @@ -0,0 +1,144 @@ +// Mistral provider — unit tests +// Uses Node.js built-in test runner (node:test) — no extra dependencies + +import { describe, it, mock, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { MistralProvider } from '../lib/llm/mistral.mjs'; +import { createLLMProvider } from '../lib/llm/index.mjs'; + +// ─── Unit Tests ─── + +describe('MistralProvider', () => { + it('should set defaults correctly', () => { + const provider = new MistralProvider({ apiKey: 'sk-test' }); + assert.equal(provider.name, 'mistral'); + assert.equal(provider.model, 'mistral-medium'); + assert.equal(provider.isConfigured, true); + }); + + it('should accept custom model', () => { + const provider = new MistralProvider({ apiKey: 'sk-test', model: 'mistral-medium-highspeed' }); + assert.equal(provider.model, 'mistral-medium-highspeed'); + }); + + it('should report not configured without API key', () => { + const provider = new MistralProvider({}); + assert.equal(provider.isConfigured, false); + }); + + it('should throw on API error', async () => { + const provider = new MistralProvider({ apiKey: 'sk-test' }); + const originalFetch = globalThis.fetch; + globalThis.fetch = mock.fn(() => + Promise.resolve({ ok: false, status: 401, text: () => Promise.resolve('Unauthorized') }) + ); + try { + await assert.rejects( + () => provider.complete('system', 'user'), + (err) => { + assert.match(err.message, /Mistral API 401/); + return true; + } + ); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('should parse successful response', async () => { + const provider = new MistralProvider({ apiKey: 'sk-test' }); + const mockResponse = { + choices: [{ message: { content: 'Hello from Mistral' } }], + usage: { prompt_tokens: 10, completion_tokens: 5 }, + model: 'mistral-medium', + }; + const originalFetch = globalThis.fetch; + globalThis.fetch = mock.fn(() => + Promise.resolve({ ok: true, json: () => Promise.resolve(mockResponse) }) + ); + try { + const result = await provider.complete('You are helpful.', 'Say hello'); + assert.equal(result.text, 'Hello from Mistral'); + assert.equal(result.usage.inputTokens, 10); + assert.equal(result.usage.outputTokens, 5); + assert.equal(result.model, 'mistral-medium'); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('should send correct request format', async () => { + const provider = new MistralProvider({ apiKey: 'sk-test-key', model: 'mistral-medium' }); + let capturedUrl, capturedOpts; + const originalFetch = globalThis.fetch; + globalThis.fetch = mock.fn((url, opts) => { + capturedUrl = url; + capturedOpts = opts; + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + choices: [{ message: { content: 'ok' } }], + usage: { prompt_tokens: 1, completion_tokens: 1 }, + model: 'mistral-medium', + }), + }); + }); + try { + await provider.complete('system prompt', 'user message', { maxTokens: 2048 }); + assert.equal(capturedUrl, 'https://api.mistral.ai/v1/chat/completions'); + assert.equal(capturedOpts.method, 'POST'); + const headers = capturedOpts.headers; + assert.equal(headers['Content-Type'], 'application/json'); + assert.equal(headers['Authorization'], 'Bearer sk-test-key'); + const body = JSON.parse(capturedOpts.body); + assert.equal(body.model, 'mistral-medium'); + assert.equal(body.max_tokens, 2048); + assert.equal(body.messages[0].role, 'system'); + assert.equal(body.messages[0].content, 'system prompt'); + assert.equal(body.messages[1].role, 'user'); + assert.equal(body.messages[1].content, 'user message'); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('should handle empty response gracefully', async () => { + const provider = new MistralProvider({ apiKey: 'sk-test' }); + const originalFetch = globalThis.fetch; + globalThis.fetch = mock.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ choices: [], usage: {} }), + }) + ); + try { + const result = await provider.complete('sys', 'user'); + assert.equal(result.text, ''); + assert.equal(result.usage.inputTokens, 0); + assert.equal(result.usage.outputTokens, 0); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); + +// ─── Factory Tests ─── + +describe('createLLMProvider — mistral', () => { + it('should create MistralProvider for provider=mistral', () => { + const provider = createLLMProvider({ provider: 'mistral', apiKey: 'sk-test', model: null }); + assert.ok(provider instanceof MistralProvider); + assert.equal(provider.name, 'mistral'); + assert.equal(provider.isConfigured, true); + }); + + it('should be case-insensitive', () => { + const provider = createLLMProvider({ provider: 'Mistral', apiKey: 'sk-test', model: null }); + assert.ok(provider instanceof MistralProvider); + }); + + it('should return null for empty provider', () => { + const provider = createLLMProvider({ provider: null, apiKey: 'sk-test', model: null }); + assert.equal(provider, null); + }); +});