From ea2e13e0c572bb381ff28581029d6cc28ef6f127 Mon Sep 17 00:00:00 2001 From: dan Date: Mon, 16 Mar 2026 20:16:43 +0000 Subject: [PATCH 1/5] feat(llm): add OpenRouter provider implementation --- lib/llm/index.mjs | 4 ++++ lib/llm/openrouter.mjs | 52 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 lib/llm/openrouter.mjs diff --git a/lib/llm/index.mjs b/lib/llm/index.mjs index 5711350..e590b0a 100644 --- a/lib/llm/index.mjs +++ b/lib/llm/index.mjs @@ -2,12 +2,14 @@ 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'; 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'; @@ -26,6 +28,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, + }; + } +} From 79dc1fd28706709808f8ce24e59a8b72d4ff8056 Mon Sep 17 00:00:00 2001 From: dan Date: Mon, 16 Mar 2026 20:16:48 +0000 Subject: [PATCH 2/5] chore: update config and env templates for OpenRouter --- .env.example | 4 ++-- crucix.config.mjs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 00b6683..4830306 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 +# Provider options: anthropic | openai | gemini | codex | openrouter 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 +# anthropic: claude-sonnet-4-6 | openai: gpt-5.4 | gemini: gemini-3.1-pro | codex: gpt-5.3-codex | openrouter: openrouter/auto LLM_MODEL= # === Telegram Alerts (optional, requires LLM) === diff --git a/crucix.config.mjs b/crucix.config.mjs index 5e7b098..e76e55c 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 + provider: process.env.LLM_PROVIDER || null, // anthropic | openai | gemini | codex | openrouter apiKey: process.env.LLM_API_KEY || null, model: process.env.LLM_MODEL || null, }, From f1352ce674a8fc25d1b4adc3d035ae33abd562bd Mon Sep 17 00:00:00 2001 From: dan Date: Mon, 16 Mar 2026 20:17:00 +0000 Subject: [PATCH 3/5] test(llm): add unit and integration tests for OpenRouter --- test/llm-openrouter.test.mjs | 104 +++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 test/llm-openrouter.test.mjs diff --git a/test/llm-openrouter.test.mjs b/test/llm-openrouter.test.mjs new file mode 100644 index 0000000..b776ae3 --- /dev/null +++ b/test/llm-openrouter.test.mjs @@ -0,0 +1,104 @@ +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; + } + }); +}); + +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'); + }); +}); From 82731bf0905e285b704512ea2da291e5c16f67e0 Mon Sep 17 00:00:00 2001 From: dan Date: Mon, 16 Mar 2026 20:17:03 +0000 Subject: [PATCH 4/5] docs: add OpenRouter to README --- README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index b522851..6651453 100644 --- a/README.md +++ b/README.md @@ -148,10 +148,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 4 LLM providers for enhanced analysis: +Connect any of 5 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) +- Providers: Anthropic Claude, OpenAI, Google Gemini, OpenRouter (Unified API), OpenAI Codex (ChatGPT subscription) - Graceful fallback — when LLM is unavailable, a rule-based engine takes over alert evaluation. LLM failures never crash the sweep cycle. --- @@ -191,6 +191,7 @@ Set `LLM_PROVIDER` to one of: `anthropic`, `openai`, `gemini`, `codex` | `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 | For Codex, run `npx @openai/codex login` to authenticate via your ChatGPT subscription. @@ -261,11 +262,12 @@ crucix/ │ └── jarvis.html # Self-contained Jarvis HUD │ ├── lib/ -│ ├── llm/ # LLM abstraction (4 providers, raw fetch, no SDKs) +│ ├── llm/ # LLM abstraction (5 providers, raw fetch, no SDKs) │ │ ├── provider.mjs # Base class │ │ ├── anthropic.mjs # Claude │ │ ├── openai.mjs # GPT │ │ ├── gemini.mjs # Gemini +│ │ ├── openrouter.mjs # OpenRouter (Unified API) │ │ ├── codex.mjs # Codex (ChatGPT subscription) │ │ ├── ideas.mjs # LLM-powered trade idea generation │ │ └── index.mjs # Factory: createLLMProvider() @@ -368,7 +370,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`, or `codex` | +| `LLM_PROVIDER` | disabled | `anthropic`, `openai`, `gemini`, `codex`, or `openrouter` | | `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 | From 0612915695a11d3595d8a12711aa32fd85c4e424 Mon Sep 17 00:00:00 2001 From: dan Date: Mon, 16 Mar 2026 21:24:46 +0000 Subject: [PATCH 5/5] test(llm): split OpenRouter unit and integration tests --- test/llm-openrouter-integration.test.mjs | 17 +++++++++++++++++ test/llm-openrouter.test.mjs | 14 -------------- 2 files changed, 17 insertions(+), 14 deletions(-) create mode 100644 test/llm-openrouter-integration.test.mjs 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 index b776ae3..bc13ac6 100644 --- a/test/llm-openrouter.test.mjs +++ b/test/llm-openrouter.test.mjs @@ -88,17 +88,3 @@ test('OpenRouterProvider Unit Tests', async (t) => { } }); }); - -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'); - }); -});