diff --git a/.env.example b/.env.example index 3585dbb..44eb01b 100644 --- a/.env.example +++ b/.env.example @@ -31,12 +31,12 @@ REFRESH_INTERVAL_MINUTES=15 # === LLM Layer (optional) === # Enables AI-enhanced trade ideas and breaking news Telegram alerts. -# Provider options: anthropic | openai | gemini | codex | minimax +# Provider options: anthropic | openai | gemini | codex | openrouter | minimax LLM_PROVIDER= # Not needed for codex (uses ~/.codex/auth.json) LLM_API_KEY= # Optional override. Each provider has a sensible default: -# anthropic: claude-sonnet-4-6 | openai: gpt-5.4 | gemini: gemini-3.1-pro | codex: gpt-5.3-codex | minimax: MiniMax-M2.5 +# anthropic: claude-sonnet-4-6 | openai: gpt-5.4 | gemini: gemini-3.1-pro | codex: gpt-5.3-codex | openrouter: openrouter/auto | minimax: MiniMax-M2.5 LLM_MODEL= # === Telegram Alerts (optional, requires LLM) === diff --git a/README.md b/README.md index 6a0095b..6bb7538 100644 --- a/README.md +++ b/README.md @@ -158,10 +158,10 @@ Alerts are delivered as rich embeds with color-coded sidebars: red for FLASH, ye **Optional dependency:** The full bot requires `discord.js`. Install it with `npm install discord.js`. If it's not installed, Crucix automatically falls back to webhook-only mode. ### Optional LLM Layer -Connect any of 5 LLM providers for enhanced analysis: +Connect any of 6 LLM providers for enhanced analysis: - **AI trade ideas** — quantitative analyst producing 5-8 actionable ideas citing specific data - **Smarter alert evaluation** — LLM classifies signals into FLASH/PRIORITY/ROUTINE tiers with cross-domain correlation and confidence scoring -- Providers: Anthropic Claude, OpenAI, Google Gemini, OpenAI Codex (ChatGPT subscription), MiniMax +- Providers: Anthropic Claude, OpenAI, Google Gemini, OpenRouter (Unified API), OpenAI Codex (ChatGPT subscription), MiniMax - Graceful fallback — when LLM is unavailable, a rule-based engine takes over alert evaluation. LLM failures never crash the sweep cycle. --- @@ -194,13 +194,14 @@ These three unlock the most valuable economic and satellite data. Each takes abo ### LLM Provider (optional, for AI-enhanced ideas) -Set `LLM_PROVIDER` to one of: `anthropic`, `openai`, `gemini`, `codex`, `minimax` +Set `LLM_PROVIDER` to one of: `anthropic`, `openai`, `gemini`, `codex`, `openrouter`, `minimax` | Provider | Key Required | Default Model | |----------|-------------|---------------| | `anthropic` | `LLM_API_KEY` | claude-sonnet-4-6 | | `openai` | `LLM_API_KEY` | gpt-5.4 | | `gemini` | `LLM_API_KEY` | gemini-3.1-pro | +| `openrouter` | `LLM_API_KEY` | openrouter/auto | | `codex` | None (uses `~/.codex/auth.json`) | gpt-5.3-codex | | `minimax` | `LLM_API_KEY` | MiniMax-M2.5 | @@ -277,6 +278,7 @@ crucix/ │ │ ├── anthropic.mjs # Claude │ │ ├── openai.mjs # GPT │ │ ├── gemini.mjs # Gemini +│ │ ├── openrouter.mjs # OpenRouter (Unified API) │ │ ├── codex.mjs # Codex (ChatGPT subscription) │ │ ├── minimax.mjs # MiniMax (M2.5, 204K context) │ │ ├── ideas.mjs # LLM-powered trade idea generation @@ -380,7 +382,7 @@ All settings are in `.env` with sensible defaults: |----------|---------|-------------| | `PORT` | `3117` | Dashboard server port | | `REFRESH_INTERVAL_MINUTES` | `15` | Auto-refresh interval | -| `LLM_PROVIDER` | disabled | `anthropic`, `openai`, `gemini`, `codex`, or `minimax` | +| `LLM_PROVIDER` | disabled | `anthropic`, `openai`, `gemini`, `codex`, `openrouter`, or `minimax` | | `LLM_API_KEY` | — | API key (not needed for codex) | | `LLM_MODEL` | per-provider default | Override model selection | | `TELEGRAM_BOT_TOKEN` | disabled | For Telegram alerts + bot commands | diff --git a/crucix.config.mjs b/crucix.config.mjs index 4d4c36b..da25ca1 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 | minimax + provider: process.env.LLM_PROVIDER || null, // anthropic | openai | gemini | codex | openrouter | minimax 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 d6d3840..7320a66 100644 --- a/lib/llm/index.mjs +++ b/lib/llm/index.mjs @@ -2,6 +2,7 @@ import { AnthropicProvider } from './anthropic.mjs'; import { OpenAIProvider } from './openai.mjs'; +import { OpenRouterProvider } from './openrouter.mjs'; import { GeminiProvider } from './gemini.mjs'; import { CodexProvider } from './codex.mjs'; import { MiniMaxProvider } from './minimax.mjs'; @@ -9,6 +10,7 @@ import { MiniMaxProvider } from './minimax.mjs'; export { LLMProvider } from './provider.mjs'; export { AnthropicProvider } from './anthropic.mjs'; export { OpenAIProvider } from './openai.mjs'; +export { OpenRouterProvider } from './openrouter.mjs'; export { GeminiProvider } from './gemini.mjs'; export { CodexProvider } from './codex.mjs'; export { MiniMaxProvider } from './minimax.mjs'; @@ -28,6 +30,8 @@ export function createLLMProvider(llmConfig) { return new AnthropicProvider({ apiKey, model }); case 'openai': return new OpenAIProvider({ apiKey, model }); + case 'openrouter': + return new OpenRouterProvider({ apiKey, model }); case 'gemini': return new GeminiProvider({ apiKey, model }); case 'codex': diff --git a/lib/llm/openrouter.mjs b/lib/llm/openrouter.mjs new file mode 100644 index 0000000..4805de8 --- /dev/null +++ b/lib/llm/openrouter.mjs @@ -0,0 +1,52 @@ +// OpenRouter Provider — raw fetch, no SDK + +import { LLMProvider } from './provider.mjs'; + +export class OpenRouterProvider extends LLMProvider { + constructor(config) { + super(config); + this.name = 'openrouter'; + this.apiKey = config.apiKey; + this.model = config.model || 'openrouter/auto'; + } + + get isConfigured() { return !!this.apiKey; } + + async complete(systemPrompt, userMessage, opts = {}) { + const res = await fetch('https://openrouter.ai/api/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiKey}`, + 'HTTP-Referer': 'https://github.com/calesthio/Crucix', + 'X-Title': 'Crucix', + }, + 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(`OpenRouter 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-openrouter-integration.test.mjs b/test/llm-openrouter-integration.test.mjs new file mode 100644 index 0000000..7d8af58 --- /dev/null +++ b/test/llm-openrouter-integration.test.mjs @@ -0,0 +1,17 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createLLMProvider } from '../lib/llm/index.mjs'; + +test('OpenRouterProvider Integration Test', { skip: !process.env.LLM_API_KEY || process.env.LLM_PROVIDER !== 'openrouter' }, async (t) => { + await t.test('Performs live API call', async () => { + const provider = createLLMProvider({ + provider: 'openrouter', + apiKey: process.env.LLM_API_KEY, + model: process.env.LLM_MODEL || 'openrouter/auto' + }); + + const result = await provider.complete('Reply with exactly "Hello".', 'Hi'); + assert.ok(result.text.length > 0, 'Should return text'); + assert.ok(result.usage.inputTokens > 0, 'Should return input token usage'); + }); +}); diff --git a/test/llm-openrouter.test.mjs b/test/llm-openrouter.test.mjs new file mode 100644 index 0000000..bc13ac6 --- /dev/null +++ b/test/llm-openrouter.test.mjs @@ -0,0 +1,90 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { OpenRouterProvider } from '../lib/llm/openrouter.mjs'; +import { createLLMProvider } from '../lib/llm/index.mjs'; + +test('OpenRouterProvider Unit Tests', async (t) => { + await t.test('initializes correctly', () => { + const provider = new OpenRouterProvider({ apiKey: 'test-key', model: 'openrouter/auto' }); + assert.equal(provider.name, 'openrouter'); + assert.equal(provider.apiKey, 'test-key'); + assert.equal(provider.model, 'openrouter/auto'); + assert.equal(provider.isConfigured, true); + }); + + await t.test('isConfigured is false without apiKey', () => { + const provider = new OpenRouterProvider({ apiKey: null }); + assert.equal(provider.isConfigured, false); + }); + + await t.test('createLLMProvider factory returns OpenRouterProvider', () => { + const provider = createLLMProvider({ provider: 'openrouter', apiKey: 'test-key', model: 'test-model' }); + assert.ok(provider instanceof OpenRouterProvider); + assert.equal(provider.apiKey, 'test-key'); + assert.equal(provider.model, 'test-model'); + }); + + await t.test('complete() returns expected result', async () => { + const provider = new OpenRouterProvider({ apiKey: 'test-key', model: 'test-model' }); + + // Mock the global fetch + const originalFetch = global.fetch; + global.fetch = async (url, options) => { + assert.equal(url, 'https://openrouter.ai/api/v1/chat/completions'); + assert.equal(options.headers['Authorization'], 'Bearer test-key'); + assert.equal(options.headers['X-Title'], 'Crucix'); + assert.equal(options.headers['HTTP-Referer'], 'https://github.com/calesthio/Crucix'); + + const body = JSON.parse(options.body); + assert.equal(body.model, 'test-model'); + assert.deepEqual(body.messages, [ + { role: 'system', content: 'You are a test.' }, + { role: 'user', content: 'Hello' } + ]); + + return { + ok: true, + json: async () => ({ + choices: [{ message: { content: 'Test response' } }], + usage: { prompt_tokens: 10, completion_tokens: 5 }, + model: 'test-model' + }) + }; + }; + + try { + const result = await provider.complete('You are a test.', 'Hello'); + assert.equal(result.text, 'Test response'); + assert.deepEqual(result.usage, { inputTokens: 10, outputTokens: 5 }); + assert.equal(result.model, 'test-model'); + } finally { + // Restore original fetch + global.fetch = originalFetch; + } + }); + + await t.test('complete() throws error on API failure', async () => { + const provider = new OpenRouterProvider({ apiKey: 'test-key', model: 'test-model' }); + + const originalFetch = global.fetch; + global.fetch = async () => { + return { + ok: false, + status: 401, + text: async () => 'Unauthorized access' + }; + }; + + try { + await assert.rejects( + provider.complete('system', 'user'), + { + name: 'Error', + message: 'OpenRouter API 401: Unauthorized access' + } + ); + } finally { + global.fetch = originalFetch; + } + }); +});