Merge branch 'master' into feat/docker-ghcr-publish

This commit is contained in:
R4V3N
2026-03-18 06:21:45 +01:00
20 changed files with 1040 additions and 83 deletions

View File

@@ -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 | 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
# 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) ===

3
.gitignore vendored
View File

@@ -43,3 +43,6 @@ npm-debug.log*
# Local maintainer notes
MAINTAINER_DECISIONS.local.md
# Local deploy config
dashboard/public/vercel.json

View File

@@ -4,6 +4,11 @@
**Your own intelligence terminal. 27 sources. One command. Zero cloud.**
## [Visit The Live Site: crucix.live](https://www.crucix.live/)
[![Live Website](https://img.shields.io/badge/live-crucix.live-00d4ff?style=for-the-badge)](https://www.crucix.live/)
[![Open Demo](https://img.shields.io/badge/open-live%20dashboard-0b1220?style=for-the-badge&logo=googlechrome&logoColor=white)](https://www.crucix.live/)
[![Node.js 22+](https://img.shields.io/badge/node-22%2B-brightgreen)](#quick-start)
[![License: AGPL v3](https://img.shields.io/badge/license-AGPLv3-blue.svg)](LICENSE)
[![Dependencies](https://img.shields.io/badge/dependencies-1%20(express)-orange)](#architecture)
@@ -27,10 +32,15 @@
</div>
> **Live website:** [https://www.crucix.live/](https://www.crucix.live/)
> Explore the public demo first, then clone the repo to run Crucix locally.
Crucix pulls satellite fire detection, flight tracking, radiation monitoring, satellite constellation tracking, economic indicators, live market prices, conflict data, sanctions lists, and social sentiment from 27 open-source intelligence feeds — in parallel, every 15 minutes — and renders everything on a single self-contained Jarvis-style dashboard.
Hook it up to an LLM and it becomes a **two-way intelligence assistant** — pushing multi-tier alerts to Telegram and Discord when something meaningful changes, responding to commands like `/brief` and `/sweep` from your phone, and generating actionable trade ideas grounded in real cross-domain data. Your own analyst that watches the world while you sleep.
Try the live demo first at [https://www.crucix.live/](https://www.crucix.live/), then clone the repo when you want the full local stack.
No cloud. No telemetry. No subscriptions. Just `node server.mjs` and you're running.
---
@@ -50,7 +60,7 @@ It was built for anyone who wants to understand what's actually happening in the
```bash
# 1. Clone the repo
git clone https://github.com/calesthio/Crucix.git
cd crucix
cd Crucix
# 2. Install dependencies (just Express)
npm install
@@ -76,7 +86,7 @@ The dashboard opens automatically at `http://localhost:3117` and immediately beg
```bash
git clone https://github.com/calesthio/Crucix.git
cd crucix
cd Crucix
cp .env.example .env # add your API keys
docker compose up -d
```
@@ -148,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 4 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)
- 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.
---
@@ -184,14 +194,16 @@ 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`
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 |
For Codex, run `npx @openai/codex login` to authenticate via your ChatGPT subscription.
@@ -261,12 +273,14 @@ 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)
│ │ ├── minimax.mjs # MiniMax (M2.5, 204K context)
│ │ ├── ideas.mjs # LLM-powered trade idea generation
│ │ └── index.mjs # Factory: createLLMProvider()
│ ├── delta/ # Change tracking between sweeps
@@ -368,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`, or `codex` |
| `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 |

View File

@@ -14,16 +14,16 @@ const RADNET_AUX = `${BASE}/RADNET_AUX`;
// Key US cities with RadNet monitoring stations
const MONITORING_STATIONS = {
washingtonDC: { label: 'Washington, DC', state: 'DC' },
newYork: { label: 'New York, NY', state: 'NY' },
losAngeles: { label: 'Los Angeles, CA', state: 'CA' },
chicago: { label: 'Chicago, IL', state: 'IL' },
seattle: { label: 'Seattle, WA', state: 'WA' },
denver: { label: 'Denver, CO', state: 'CO' },
honolulu: { label: 'Honolulu, HI', state: 'HI' },
anchorage: { label: 'Anchorage, AK', state: 'AK' },
miami: { label: 'Miami, FL', state: 'FL' },
sanFrancisco: { label: 'San Francisco, CA', state: 'CA' },
washingtonDC: { label: 'Washington, DC', state: 'DC', lat: 38.9, lon: -77.0 },
newYork: { label: 'New York, NY', state: 'NY', lat: 40.7, lon: -74.0 },
losAngeles: { label: 'Los Angeles, CA', state: 'CA', lat: 34.1, lon: -118.2 },
chicago: { label: 'Chicago, IL', state: 'IL', lat: 41.9, lon: -87.6 },
seattle: { label: 'Seattle, WA', state: 'WA', lat: 47.6, lon: -122.3 },
denver: { label: 'Denver, CO', state: 'CO', lat: 39.7, lon: -105.0 },
honolulu: { label: 'Honolulu, HI', state: 'HI', lat: 21.3, lon: -157.9 },
anchorage: { label: 'Anchorage, AK', state: 'AK', lat: 61.2, lon: -149.9 },
miami: { label: 'Miami, FL', state: 'FL', lat: 25.8, lon: -80.2 },
sanFrancisco: { label: 'San Francisco, CA', state: 'CA', lat: 37.8, lon: -122.4 },
};
// Analyte types that indicate concerning radiation
@@ -76,8 +76,15 @@ export async function getResultsByAnalyte(analyte, opts = {}) {
);
}
// Lookup coords by city name or state
const CITY_COORDS = Object.fromEntries(
Object.values(MONITORING_STATIONS).map(s => [s.label.split(',')[0].toUpperCase(), s])
);
// Compact a reading for briefing output
function compactReading(r) {
const city = (r.ANA_CITY || r.LOCATION || '').toUpperCase().trim();
const station = CITY_COORDS[city];
return {
location: r.ANA_CITY || r.LOCATION || 'Unknown',
state: r.ANA_STATE || r.STATE || null,
@@ -86,6 +93,8 @@ function compactReading(r) {
unit: r.RESULT_UNIT || r.ANA_UNIT || null,
collectDate: r.COLLECT_DATE || r.SAMPLE_DATE || null,
medium: r.SAMPLE_TYPE || r.MEDIUM || null,
lat: station?.lat || null,
lon: station?.lon || null,
};
}

View File

@@ -104,11 +104,26 @@ export async function briefing() {
keywords.some(k => a.title?.toLowerCase().includes(k))
);
// Geo events — get mapped event locations (separate API, respects rate limit)
await delay(5500);
let geoPoints = [];
try {
const geo = await geoEvents('conflict OR military OR protest OR crisis', { maxPoints: 30, timespan: '24h' });
geoPoints = (geo?.features || []).filter(f => f.geometry?.coordinates).map(f => ({
lat: f.geometry.coordinates[1],
lon: f.geometry.coordinates[0],
name: f.properties?.name || f.properties?.html || '',
count: f.properties?.count || 1,
type: f.properties?.type || 'event',
}));
} catch (e) { /* geo endpoint optional — don't break briefing */ }
return {
source: 'GDELT',
timestamp: new Date().toISOString(),
totalArticles: articles.length,
allArticles: articles,
geoPoints,
conflicts: categorize(['military', 'conflict', 'war', 'strike', 'missile', 'attack', 'bomb', 'troops']),
economy: categorize(['economy', 'recession', 'inflation', 'market', 'sanctions', 'tariff', 'trade', 'gdp']),
health: categorize(['pandemic', 'outbreak', 'epidemic', 'disease', 'virus', 'health']),

View File

@@ -57,15 +57,33 @@ export async function briefing() {
wildfires: fire.length,
other: other.length,
},
topAlerts: features.slice(0, 15).map(f => ({
event: f.properties?.event,
severity: f.properties?.severity,
urgency: f.properties?.urgency,
headline: f.properties?.headline,
areas: f.properties?.areaDesc,
onset: f.properties?.onset,
expires: f.properties?.expires,
})),
topAlerts: features.slice(0, 15).map(f => {
// Extract centroid from GeoJSON geometry
let lat = null, lon = null;
const geo = f.geometry;
if (geo?.type === 'Polygon' && geo.coordinates?.[0]?.length) {
const coords = geo.coordinates[0];
lat = coords.reduce((s, c) => s + c[1], 0) / coords.length;
lon = coords.reduce((s, c) => s + c[0], 0) / coords.length;
} else if (geo?.type === 'MultiPolygon' && geo.coordinates?.length) {
const coords = geo.coordinates[0][0];
lat = coords.reduce((s, c) => s + c[1], 0) / coords.length;
lon = coords.reduce((s, c) => s + c[0], 0) / coords.length;
} else if (geo?.type === 'Point') {
[lon, lat] = geo.coordinates;
}
return {
event: f.properties?.event,
severity: f.properties?.severity,
urgency: f.properties?.urgency,
headline: f.properties?.headline,
areas: f.properties?.areaDesc,
onset: f.properties?.onset,
expires: f.properties?.expires,
lat: lat != null ? +lat.toFixed(3) : null,
lon: lon != null ? +lon.toFixed(3) : null,
};
}),
};
}

View File

@@ -57,6 +57,10 @@ const HOTSPOTS = {
baltics: { lamin: 53, lomin: 19, lamax: 60, lomax: 29, label: 'Baltic Region' },
southChinaSea: { lamin: 5, lomin: 105, lamax: 23, lomax: 122, label: 'South China Sea' },
koreanPeninsula: { lamin: 33, lomin: 124, lamax: 43, lomax: 132, label: 'Korean Peninsula' },
caribbean: { lamin: 18, lomin: -90, lamax: 30, lomax: -72, label: 'Caribbean' },
gulfOfGuinea: { lamin: -2, lomin: -5, lamax: 8, lomax: 10, label: 'Gulf of Guinea' },
capeRoute: { lamin: -38, lomin: 12, lamax: -28, lomax: 24, label: 'Cape Route' },
hornOfAfrica: { lamin: 5, lomin: 40, lamax: 15, lomax: 55, label: 'Horn of Africa' },
};
// Briefing — check hotspot regions for flight activity

View File

@@ -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 | minimax
apiKey: process.env.LLM_API_KEY || null,
model: process.env.LLM_MODEL || null,
},

View File

@@ -9,6 +9,9 @@ import { readFileSync, writeFileSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import { exec } from 'child_process';
import config from '../crucix.config.mjs';
import { createLLMProvider } from '../lib/llm/index.mjs';
import { generateLLMIdeas } from '../lib/llm/ideas.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..');
@@ -125,7 +128,7 @@ export async function fetchAllNews() {
const feeds = [
['http://feeds.bbci.co.uk/news/world/rss.xml', 'BBC'],
['https://rss.nytimes.com/services/xml/rss/nyt/World.xml', 'NYT'],
['https://feeds.aljazeera.com/xml/rss/all.xml', 'Al Jazeera'],
['https://www.aljazeera.com/xml/rss/all.xml', 'Al Jazeera'],
['https://rss.nytimes.com/services/xml/rss/nyt/Americas.xml', 'NYT Americas'],
['https://rss.nytimes.com/services/xml/rss/nyt/AsiaPacific.xml', 'NYT Asia'],
['https://feeds.bbci.co.uk/news/technology/rss.xml', 'BBC Tech'],
@@ -364,16 +367,54 @@ export async function synthesize(data) {
const defense = (data.sources.USAspending?.recentDefenseContracts || []).slice(0, 5).map(c => ({
recipient: c.recipient?.substring(0, 40), amount: c.amount, desc: c.description?.substring(0, 80)
}));
const noaa = { totalAlerts: data.sources.NOAA?.totalSevereAlerts || 0 };
const noaa = {
totalAlerts: data.sources.NOAA?.totalSevereAlerts || 0,
alerts: (data.sources.NOAA?.topAlerts || []).filter(a => a.lat != null && a.lon != null).slice(0, 10).map(a => ({
event: a.event, severity: a.severity, headline: a.headline?.substring(0, 120),
lat: a.lat, lon: a.lon
}))
};
// EPA RadNet — pass through geo-tagged readings
const epaData = data.sources.EPA || {};
const epaStations = [];
const seenEpa = new Set();
for (const r of (epaData.readings || [])) {
if (r.lat == null || r.lon == null) continue;
const key = `${r.lat},${r.lon}`;
if (seenEpa.has(key)) continue;
seenEpa.add(key);
epaStations.push({ location: r.location, state: r.state, lat: r.lat, lon: r.lon, analyte: r.analyte, result: r.result, unit: r.unit });
}
const epa = { totalReadings: epaData.totalReadings || 0, stations: epaStations.slice(0, 10) };
// Space/CelesTrak satellite data
const spaceData = data.sources.Space || {};
// Approximate subsatellite position from TLE orbital elements
function estimateSatPosition(sat) {
if (!sat?.inclination || !sat?.epoch) return null;
const epoch = new Date(sat.epoch);
const now = new Date();
const elapsed = (now - epoch) / 1000;
const period = (sat.period || 92.7) * 60; // minutes to seconds
const orbits = elapsed / period;
const frac = orbits % 1;
const lat = sat.inclination * Math.sin(frac * 2 * Math.PI);
const lonShift = (elapsed / 86400) * 360;
const orbitLon = frac * 360;
const lon = ((orbitLon - lonShift) % 360 + 540) % 360 - 180;
return { lat: +lat.toFixed(2), lon: +lon.toFixed(2), name: sat.name };
}
const issPos = estimateSatPosition(spaceData.iss);
const spaceStations = (spaceData.spaceStations || []).map(s => estimateSatPosition(s)).filter(Boolean);
const space = {
totalNewObjects: spaceData.totalNewObjects || 0,
militarySats: spaceData.militarySatellites || 0,
militaryByCountry: spaceData.militaryByCountry || {},
constellations: spaceData.constellations || {},
iss: spaceData.iss || null,
issPosition: issPos,
stationPositions: spaceStations.slice(0, 5),
recentLaunches: (spaceData.recentLaunches || []).slice(0, 10).map(l => ({
name: l.name, country: l.country, epoch: l.epoch,
apogee: l.apogee, perigee: l.perigee, type: l.objectType
@@ -395,7 +436,7 @@ export async function synthesize(data) {
}))
};
// GDELT news articles
// GDELT news articles + geo events
const gdeltData = data.sources.GDELT || {};
const gdelt = {
totalArticles: gdeltData.totalArticles || 0,
@@ -403,7 +444,10 @@ export async function synthesize(data) {
economy: (gdeltData.economy || []).length,
health: (gdeltData.health || []).length,
crisis: (gdeltData.crisis || []).length,
topTitles: (gdeltData.allArticles || []).slice(0, 5).map(a => a.title?.substring(0, 80))
topTitles: (gdeltData.allArticles || []).slice(0, 5).map(a => a.title?.substring(0, 80)),
geoPoints: (gdeltData.geoPoints || []).slice(0, 20).map(p => ({
lat: p.lat, lon: p.lon, name: (p.name || '').substring(0, 80), count: p.count || 1
}))
};
const health = Object.entries(data.sources).map(([name, src]) => ({
@@ -454,7 +498,7 @@ export async function synthesize(data) {
meta: data.crucix, air, thermal, tSignals, chokepoints, nuke, nukeSignals,
sdr: { total: sdrNet.totalReceivers || 0, online: sdrNet.online || 0, zones: sdrZones },
tg: { posts: tgData.totalPosts || 0, urgent: tgUrgent, topPosts: tgTop },
who, fred, energy, bls, treasury, gscpi, defense, noaa, acled, gdelt, space, health, news,
who, fred, energy, bls, treasury, gscpi, defense, noaa, epa, acled, gdelt, space, health, news,
markets, // Live Yahoo Finance market data
ideas: [], ideasSource: 'disabled',
// newsFeed for ticker (merged RSS + GDELT + Telegram)
@@ -511,11 +555,42 @@ function buildNewsFeed(rssNews, gdeltData, tgUrgent, tgTop) {
}
// === CLI Mode: inject into HTML file ===
function getCliArg(flag) {
const idx = process.argv.indexOf(flag);
return idx >= 0 ? process.argv[idx + 1] : null;
}
async function cliInject() {
const data = JSON.parse(readFileSync(join(ROOT, 'runs/latest.json'), 'utf8'));
const htmlOverride = getCliArg('--html');
const shouldOpen = !process.argv.includes('--no-open');
console.log('Fetching RSS news feeds...');
const V2 = await synthesize(data);
const llmProvider = createLLMProvider(config.llm);
if (llmProvider?.isConfigured) {
try {
console.log(`[LLM] Generating ideas via ${llmProvider.name}...`);
const llmIdeas = await generateLLMIdeas(llmProvider, V2, null, []);
if (llmIdeas?.length) {
V2.ideas = llmIdeas;
V2.ideasSource = 'llm';
console.log(`[LLM] Generated ${llmIdeas.length} ideas`);
} else {
V2.ideas = [];
V2.ideasSource = 'llm-failed';
console.log('[LLM] No ideas returned');
}
} catch (err) {
V2.ideas = [];
V2.ideasSource = 'llm-failed';
console.log('[LLM] Idea generation failed:', err.message);
}
} else {
V2.ideas = [];
V2.ideasSource = 'disabled';
}
console.log(`Generated ${V2.ideas.length} leverageable ideas`);
const json = JSON.stringify(V2);
@@ -523,12 +598,15 @@ async function cliInject() {
console.log('Size:', json.length, 'bytes | Air:', V2.air.length, '| Thermal:', V2.thermal.length,
'| News:', V2.news.length, '| Ideas:', V2.ideas.length, '| Sources:', V2.health.length);
const htmlPath = join(ROOT, 'dashboard/public/jarvis.html');
const htmlPath = htmlOverride || join(ROOT, 'dashboard/public/jarvis.html');
let html = readFileSync(htmlPath, 'utf8');
html = html.replace(/^(let|const) D = .*;\s*$/m, 'let D = ' + json + ';');
// Use a replacer function so JSON is inserted literally even if it contains `$`.
html = html.replace(/^(let|const) D = .*;\s*$/m, () => 'let D = ' + json + ';');
writeFileSync(htmlPath, html);
console.log('Data injected into jarvis.html!');
if (!shouldOpen) return;
// Auto-open dashboard in default browser
// NOTE: On Windows, `start` in PowerShell is an alias for Start-Service, not cmd's start.
// We must use `cmd /c start ""` to ensure it works in both cmd.exe and PowerShell.
@@ -542,7 +620,8 @@ async function cliInject() {
}
// Run CLI if invoked directly
const isMain = process.argv[1] && fileURLToPath(import.meta.url).includes(process.argv[1].replace(/\\/g, '/'));
const isMain = process.argv[1]
&& fileURLToPath(import.meta.url).replace(/\\/g, '/') === process.argv[1].replace(/\\/g, '/');
if (isMain) {
cliInject();
await cliInject();
}

View File

@@ -298,12 +298,12 @@ let flightsVisible = true;
let isFlat = true;
let flatSvg, flatProjection, flatPath, flatG, flatZoom, flatW, flatH;
const regionPOV = {
world: { lat: 20, lng: 20, altitude: 2.5 },
americas: { lat: 15, lng: -80, altitude: 1.6 },
europe: { lat: 50, lng: 15, altitude: 1.2 },
middleEast: { lat: 28, lng: 45, altitude: 1.4 },
asiaPacific: { lat: 25, lng: 110, altitude: 1.6 },
africa: { lat: 5, lng: 20, altitude: 1.5 }
world: { lat: 20, lng: 20, altitude: 1.8 },
americas: { lat: 35, lng: -95, altitude: 1.0 },
europe: { lat: 50, lng: 15, altitude: 1.0 },
middleEast: { lat: 28, lng: 45, altitude: 1.1 },
asiaPacific: { lat: 25, lng: 110, altitude: 1.2 },
africa: { lat: 5, lng: 20, altitude: 1.2 }
};
// === TOPBAR ===
@@ -414,7 +414,7 @@ function initMap(){
.pointRadius(d => d.size || 0.3)
.pointColor(d => d.color)
.pointLabel(d => `<b>${d.popHead||''}</b><br><span style="opacity:0.7">${d.popMeta||''}</span>`)
.onPointClick((pt, ev) => { showPopup(ev, pt.popHead, pt.popText, pt.popMeta); })
.onPointClick((pt, ev) => { showPopup(ev, pt.popHead, pt.popText, pt.popMeta, pt.lat, pt.lng, pt.alt); })
.onPointHover(pt => { document.getElementById('globeViz').style.cursor = pt ? 'pointer' : 'grab'; })
// Arcs layer (flight corridors)
.arcColor(d => d.color)
@@ -502,7 +502,7 @@ function initMap(){
// Legend
document.getElementById('mapLegend').innerHTML=
[{c:'#64f0c8',l:'Air Traffic'},{c:'#ff5f63',l:'Thermal/Fire'},{c:'rgba(255,120,80,0.8)',l:'Conflict'},{c:'#44ccff',l:'SDR Receiver'},
{c:'#ffe082',l:'Nuclear Site'},{c:'#b388ff',l:'Chokepoint'},{c:'#ffb84c',l:'OSINT Event'},{c:'#69f0ae',l:'Health Alert'},{c:'#81d4fa',l:'World News'}]
{c:'#ffe082',l:'Nuclear Site'},{c:'#b388ff',l:'Chokepoint'},{c:'#ffb84c',l:'OSINT Event'},{c:'#69f0ae',l:'Health Alert'},{c:'#81d4fa',l:'World News'},{c:'#ff9800',l:'Weather Alert'},{c:'#cddc39',l:'EPA RadNet'},{c:'#ffffff',l:'Space Station'},{c:'#6495ed',l:'GDELT Event'}]
.map(x=>`<div class="leg-item"><div class="leg-dot" style="background:${x.c}"></div>${x.l}</div>`).join('');
}
@@ -511,12 +511,12 @@ function plotMarkers(){
const labels = [];
// === Air hotspots (green) ===
const airCoords=[{lat:30,lon:44},{lat:24,lon:120},{lat:49,lon:32},{lat:57,lon:24},{lat:14,lon:114},{lat:37,lon:127}];
const airCoords=[{lat:30,lon:44},{lat:24,lon:120},{lat:49,lon:32},{lat:57,lon:24},{lat:14,lon:114},{lat:37,lon:127},{lat:25,lon:-80},{lat:4,lon:2},{lat:-34,lon:18},{lat:10,lon:51}];
D.air.forEach((a,i)=>{
const c=airCoords[i]; if(!c) return;
points.push({
lat:c.lat, lng:c.lon, size:0.25+a.total/200, alt:0.015,
color:'rgba(100,240,200,0.8)', type:'air',
color:'rgba(100,240,200,0.8)', type:'air', priority:1,
label: a.region.replace(' Region','')+' '+a.total,
popHead: a.region, popMeta: 'Air Activity',
popText: `${a.total} aircraft tracked<br>No callsign: ${a.noCallsign}<br>High altitude: ${a.highAlt}<br>Top: ${a.top.slice(0,3).map(t=>t[0]+' ('+t[1]+')').join(', ')}`
@@ -529,7 +529,7 @@ function plotMarkers(){
t.fires.forEach(f=>{
points.push({
lat:f.lat, lng:f.lon, size:0.12+Math.min(f.frp/200,0.3), alt:0.008,
color:'rgba(255,95,99,0.7)', type:'thermal',
color:'rgba(255,95,99,0.7)', type:'thermal', priority:3,
popHead:'Thermal Detection', popMeta:'FIRMS Satellite',
popText:`Region: ${t.region}<br>FRP: ${f.frp.toFixed(1)} MW<br>Total: ${t.det.toLocaleString()}<br>Night: ${t.night.toLocaleString()}`
});
@@ -540,7 +540,7 @@ function plotMarkers(){
D.chokepoints.forEach(cp=>{
points.push({
lat:cp.lat, lng:cp.lon, size:0.35, alt:0.02,
color:'rgba(179,136,255,0.8)', type:'maritime',
color:'rgba(179,136,255,0.8)', type:'maritime', priority:1,
popHead:cp.label, popMeta:'Maritime Intelligence', popText:cp.note
});
labels.push({lat:cp.lat, lng:cp.lon+1.5, text:cp.label, size:0.3, color:'rgba(179,136,255,0.6)'});
@@ -552,7 +552,7 @@ function plotMarkers(){
const c=nukeCoords[i]; if(!c) return;
points.push({
lat:c.lat, lng:c.lon, size:0.3, alt:0.012,
color: n.anom ? 'rgba(255,95,99,0.9)' : 'rgba(255,224,130,0.8)', type:'nuke',
color: n.anom ? 'rgba(255,95,99,0.9)' : 'rgba(255,224,130,0.8)', type:'nuke', priority:2,
popHead:n.site, popMeta:'Radiation Monitoring',
popText:`Status: ${n.anom?'ANOMALY':'Normal'}<br>Avg CPM: ${n.cpm?.toFixed(1)||'No data'}<br>Readings: ${n.n}`
});
@@ -563,7 +563,7 @@ function plotMarkers(){
z.receivers.forEach(r=>{
points.push({
lat:r.lat, lng:r.lon, size:0.15, alt:0.005,
color:'rgba(68,204,255,0.6)', type:'sdr',
color:'rgba(68,204,255,0.6)', type:'sdr', priority:3,
popHead:'SDR Receiver', popMeta:'KiwiSDR Network',
popText:`${r.name}<br>Zone: ${z.region}<br>${z.count} in zone`
});
@@ -576,7 +576,7 @@ function plotMarkers(){
const post=D.tg.urgent[o.idx]; if(!post) return;
points.push({
lat:o.lat, lng:o.lon, size:0.3, alt:0.018,
color:'rgba(255,184,76,0.8)', type:'osint',
color:'rgba(255,184,76,0.8)', type:'osint', priority:2,
popHead:(post.channel||'').toUpperCase(), popMeta:`${post.views?.toLocaleString()||'?'} views`,
popText:cleanText(post.text?.substring(0,200)||'')
});
@@ -588,7 +588,7 @@ function plotMarkers(){
const c=whoGeo[i]; if(!c) return;
points.push({
lat:c.lat, lng:c.lon, size:0.25, alt:0.01,
color:'rgba(105,240,174,0.7)', type:'health',
color:'rgba(105,240,174,0.7)', type:'health', priority:2,
popHead:w.title, popMeta:'WHO Outbreak', popText:w.summary||''
});
});
@@ -597,12 +597,53 @@ function plotMarkers(){
(D.news||[]).forEach(n=>{
points.push({
lat:n.lat, lng:n.lon, size:0.2, alt:0.008,
color:'rgba(129,212,250,0.7)', type:'news',
color:'rgba(129,212,250,0.7)', type:'news', priority:3,
popHead:n.source+' NEWS', popMeta:n.region+' · '+getAge(n.date),
popText:cleanText(n.title)
});
});
// === NOAA severe weather alerts (orange) ===
(D.noaa?.alerts||[]).forEach(a=>{
points.push({
lat:a.lat, lng:a.lon, size:0.22, alt:0.01,
color:'rgba(255,152,0,0.8)', type:'weather', priority:2,
popHead:a.event, popMeta:'NOAA/NWS · '+a.severity,
popText:a.headline||''
});
});
// === EPA RadNet stations (lime green) ===
(D.epa?.stations||[]).forEach(s=>{
points.push({
lat:s.lat, lng:s.lon, size:0.18, alt:0.006,
color:'rgba(205,220,57,0.7)', type:'radiation', priority:3,
popHead:'RadNet: '+s.location, popMeta:'EPA Radiation Monitor',
popText:`${s.analyte||'--'}: ${s.result||'--'} ${s.unit||''}<br>State: ${s.state}`
});
});
// === ISS + Space Stations (bright white, pulsing) ===
(D.space?.stationPositions||[]).forEach(s=>{
points.push({
lat:s.lat, lng:s.lon, size:0.4, alt:0.04,
color:'rgba(255,255,255,0.95)', type:'space', priority:1,
popHead:s.name, popMeta:'Space Station (approx.)',
popText:`Orbital position estimate<br>Lat: ${s.lat}° Lon: ${s.lon}°`
});
labels.push({lat:s.lat, lng:s.lon+3, text:s.name.split('(')[0].trim(), size:0.35, color:'rgba(255,255,255,0.7)'});
});
// === GDELT geo events (steel blue) ===
(D.gdelt?.geoPoints||[]).forEach(g=>{
points.push({
lat:g.lat, lng:g.lon, size:0.15+Math.min(g.count/50,0.2), alt:0.007,
color:'rgba(100,149,237,0.6)', type:'gdelt', priority:3,
popHead:'GDELT Event', popMeta:g.count+' reports',
popText:g.name||'Global event detection'
});
});
// Set points on globe
globe.pointsData(points);
globe.labelsData(labels);
@@ -625,7 +666,9 @@ function plotMarkers(){
const airCoordsFlight = [
{region:'Middle East',lat:30,lon:44}, {region:'Taiwan Strait',lat:24,lon:120},
{region:'Ukraine Region',lat:49,lon:32}, {region:'Baltic Region',lat:57,lon:24},
{region:'South China Sea',lat:14,lon:114}, {region:'Korean Peninsula',lat:37,lon:127}
{region:'South China Sea',lat:14,lon:114}, {region:'Korean Peninsula',lat:37,lon:127},
{region:'Caribbean',lat:25,lon:-80}, {region:'Gulf of Guinea',lat:4,lon:2},
{region:'Cape Route',lat:-34,lon:18}, {region:'Horn of Africa',lat:10,lon:51}
];
const globalHubs = [
{lat:40.6,lon:-73.8},{lat:51.5,lon:-0.5},{lat:25.3,lon:55.4},
@@ -667,14 +710,41 @@ function plotMarkers(){
});
});
globe.arcsData(arcs);
// Zoom-aware marker sizing: scale markers and labels with camera altitude
const onGlobeZoom = () => {
const alt = globe.pointOfView().altitude;
const sf = Math.max(0.6, Math.min(2.5, 1.5 / alt));
globe.pointRadius(d => (d.size || 0.3) * sf);
// Hide labels when zoomed far out to reduce clutter
const showLabels = alt < 1.8;
globe.labelSize(d => showLabels ? (d.size || 0.4) : 0);
// Scale arc strokes with zoom
globe.arcStroke(d => (d.stroke || 0.4) * Math.max(0.5, Math.min(1.5, 1.2 / alt)));
// Priority-based point visibility: hide low-priority markers when zoomed out
if(alt > 2.0){
globe.pointsData(points.filter(p => (p.priority||3) <= 1));
} else if(alt > 1.2){
globe.pointsData(points.filter(p => (p.priority||3) <= 2));
} else {
globe.pointsData(points);
}
};
if(typeof globe.onZoom==='function') globe.onZoom(onGlobeZoom);
}
function showPopup(event,head,text,meta){
function showPopup(event,head,text,meta,lat,lng,alt){
const popup=document.getElementById('mapPopup');
const container=document.getElementById('mapContainer');
const rect=container.getBoundingClientRect();
let left, top;
if(event && event.clientX != null){
if(!isFlat && lat!=null && globe && typeof globe.getScreenCoords==='function'){
const sc=globe.getScreenCoords(lat,lng,alt||0.01);
if(!sc||isNaN(sc.x)||isNaN(sc.y)||sc.x<0||sc.y<0||sc.x>rect.width||sc.y>rect.height){
if(event&&event.clientX!=null){left=event.clientX-rect.left+10;top=event.clientY-rect.top-10;}
else return;
} else {left=sc.x+10;top=sc.y-10;}
} else if(event && event.clientX != null){
left=event.clientX - rect.left + 10;
top=event.clientY - rect.top - 10;
} else {
@@ -711,7 +781,7 @@ function toggleFlights() {
// === FLAT/GLOBE TOGGLE ===
const flatRegionBounds = {
world:[[-180,-60],[180,80]], americas:[[-170,-56],[-30,72]], europe:[[-12,34],[45,72]],
world:[[-180,-60],[180,80]], americas:[[-130,10],[-60,55]], europe:[[-12,34],[45,72]],
middleEast:[[24,10],[65,45]], asiaPacific:[[60,-12],[180,55]], africa:[[-20,-36],[55,38]]
};
@@ -745,7 +815,15 @@ function initFlatMap(){
flatG.attr('transform',event.transform);
const k=event.transform.k;
flatG.selectAll('.marker-circle').attr('r',function(){return +this.dataset.baseR/Math.sqrt(k)});
flatG.selectAll('.marker-label').style('font-size',Math.max(7,9/Math.sqrt(k))+'px');
flatG.selectAll('.marker-label').style('font-size',Math.max(7,9/Math.sqrt(k))+'px')
.style('display',k>=2.5?'block':'none');
// Priority-based visibility: hide low-priority markers at low zoom
flatG.selectAll('[data-priority]').style('display',function(){
const p=+this.dataset.priority;
if(p<=1) return 'block';
if(p<=2) return k>=2?'block':'none';
return k>=3.5?'block':'none';
});
});
flatSvg.call(flatZoom);
drawFlatMap();
@@ -765,58 +843,69 @@ function drawFlatMap(){
function plotFlatMarkers(){
const mg=flatG.append('g').attr('class','markers');
const proj=flatProjection;
const addPt=(lat,lon,r,fill,stroke,onClick)=>{
const addPt=(lat,lon,r,fill,stroke,onClick,priority)=>{
const[x,y]=proj([lon,lat]);if(!x||!y)return null;
const g=mg.append('g').attr('transform',`translate(${x},${y})`).style('cursor','pointer');
const g=mg.append('g').attr('transform',`translate(${x},${y})`).style('cursor','pointer').attr('data-priority',priority||3);
if(onClick) g.on('click',ev=>{ev.stopPropagation();onClick(ev)});
g.append('circle').attr('class','marker-circle').attr('r',r).attr('data-base-r',r).attr('fill',fill).attr('stroke',stroke).attr('stroke-width',0.8);
return g;
};
// Air
const airCoords=[{lat:30,lon:44},{lat:24,lon:120},{lat:49,lon:32},{lat:57,lon:24},{lat:14,lon:114},{lat:37,lon:127}];
const airCoords=[{lat:30,lon:44},{lat:24,lon:120},{lat:49,lon:32},{lat:57,lon:24},{lat:14,lon:114},{lat:37,lon:127},{lat:25,lon:-80},{lat:4,lon:2},{lat:-34,lon:18},{lat:10,lon:51}];
D.air.forEach((a,i)=>{
const c=airCoords[i];if(!c)return;
const g=addPt(c.lat,c.lon,4+a.total/40,'rgba(100,240,200,0.7)','rgba(100,240,200,0.3)',
ev=>showPopup(ev,a.region,`${a.total} aircraft<br>No callsign: ${a.noCallsign}<br>High alt: ${a.highAlt}`,'Air Activity'));
ev=>showPopup(ev,a.region,`${a.total} aircraft<br>No callsign: ${a.noCallsign}<br>High alt: ${a.highAlt}`,'Air Activity'),1);
if(g) g.append('text').attr('class','marker-label').attr('x',10).attr('y',3).attr('fill','var(--dim)').attr('font-size','9px').attr('font-family','var(--mono)').text(a.region.replace(' Region','')+' '+a.total);
});
// Thermal
D.thermal.forEach(t=>t.fires.forEach(f=>{
addPt(f.lat,f.lon,2+Math.min(f.frp/50,5),'rgba(255,95,99,0.6)','rgba(255,95,99,0.2)',
ev=>showPopup(ev,'Thermal',`${t.region}<br>FRP: ${f.frp.toFixed(1)} MW`,'FIRMS'));
ev=>showPopup(ev,'Thermal',`${t.region}<br>FRP: ${f.frp.toFixed(1)} MW`,'FIRMS'),3);
}));
// Chokepoints
D.chokepoints.forEach(cp=>{
const[x,y]=proj([cp.lon,cp.lat]);if(!x||!y)return;
const g=mg.append('g').attr('transform',`translate(${x},${y})`).style('cursor','pointer')
const g=mg.append('g').attr('transform',`translate(${x},${y})`).style('cursor','pointer').attr('data-priority',1)
.on('click',ev=>{ev.stopPropagation();showPopup(ev,cp.label,cp.note,'Maritime')});
g.append('rect').attr('x',-4).attr('y',-4).attr('width',8).attr('height',8).attr('fill','rgba(179,136,255,0.7)').attr('stroke','rgba(179,136,255,0.3)').attr('stroke-width',0.5).attr('transform','rotate(45)');
g.append('text').attr('class','marker-label').attr('x',8).attr('y',3).attr('fill','var(--dim)').attr('font-size','8px').attr('font-family','var(--mono)').text(cp.label);
});
// Nuclear
const nukeCoords=[{lat:47.5,lon:34.6},{lat:51.4,lon:30.1},{lat:28.8,lon:50.9},{lat:39.8,lon:125.8},{lat:37.4,lon:141},{lat:31.0,lon:35.1}];
D.nuke.forEach((n,i)=>{const c=nukeCoords[i];if(!c)return;addPt(c.lat,c.lon,4,'rgba(255,224,130,0.7)','rgba(255,224,130,0.3)',ev=>showPopup(ev,n.site,`CPM: ${n.cpm?.toFixed(1)||'--'}`,'Radiation'))});
D.nuke.forEach((n,i)=>{const c=nukeCoords[i];if(!c)return;addPt(c.lat,c.lon,4,'rgba(255,224,130,0.7)','rgba(255,224,130,0.3)',ev=>showPopup(ev,n.site,`CPM: ${n.cpm?.toFixed(1)||'--'}`,'Radiation'),2)});
// SDR
D.sdr.zones.forEach(z=>z.receivers.forEach(r=>{addPt(r.lat,r.lon,2.5,'rgba(68,204,255,0.5)','rgba(68,204,255,0.2)',ev=>showPopup(ev,'SDR',`${r.name}<br>${z.region}`,'KiwiSDR'))}));
D.sdr.zones.forEach(z=>z.receivers.forEach(r=>{addPt(r.lat,r.lon,2.5,'rgba(68,204,255,0.5)','rgba(68,204,255,0.2)',ev=>showPopup(ev,'SDR',`${r.name}<br>${z.region}`,'KiwiSDR'),3)}));
// OSINT
const osintGeo=[{lat:45,lon:41,idx:0},{lat:48,lon:37,idx:1},{lat:48.5,lon:37.5,idx:2},{lat:45,lon:40.2,idx:3},{lat:50.6,lon:36.6,idx:5},{lat:48.5,lon:35,idx:6}];
osintGeo.forEach(o=>{const p=D.tg.urgent[o.idx];if(!p)return;addPt(o.lat,o.lon,4,'rgba(255,184,76,0.7)','rgba(255,184,76,0.3)',ev=>showPopup(ev,(p.channel||'').toUpperCase(),cleanText(p.text?.substring(0,200)||''),`${p.views||'?'} views`))});
osintGeo.forEach(o=>{const p=D.tg.urgent[o.idx];if(!p)return;addPt(o.lat,o.lon,4,'rgba(255,184,76,0.7)','rgba(255,184,76,0.3)',ev=>showPopup(ev,(p.channel||'').toUpperCase(),cleanText(p.text?.substring(0,200)||''),`${p.views||'?'} views`),2)});
// WHO
const whoGeo=[{lat:0.3,lon:32.6},{lat:-6.2,lon:106.8},{lat:-4.3,lon:15.3},{lat:35,lon:105},{lat:12.5,lon:105},{lat:35,lon:105},{lat:28,lon:84},{lat:24,lon:45},{lat:30,lon:70},{lat:-0.8,lon:11.6}];
D.who.slice(0,10).forEach((w,i)=>{const c=whoGeo[i];if(!c)return;addPt(c.lat,c.lon,3.5,'rgba(105,240,174,0.6)','rgba(105,240,174,0.2)',ev=>showPopup(ev,w.title,w.summary||'','WHO'))});
D.who.slice(0,10).forEach((w,i)=>{const c=whoGeo[i];if(!c)return;addPt(c.lat,c.lon,3.5,'rgba(105,240,174,0.6)','rgba(105,240,174,0.2)',ev=>showPopup(ev,w.title,w.summary||'','WHO'),2)});
// News
(D.news||[]).forEach(n=>{addPt(n.lat,n.lon,3,'rgba(129,212,250,0.6)','rgba(129,212,250,0.2)',ev=>showPopup(ev,n.source+' NEWS',cleanText(n.title),n.region))});
(D.news||[]).forEach(n=>{addPt(n.lat,n.lon,3,'rgba(129,212,250,0.6)','rgba(129,212,250,0.2)',ev=>showPopup(ev,n.source+' NEWS',cleanText(n.title),n.region),3)});
// NOAA weather
(D.noaa?.alerts||[]).forEach(a=>{addPt(a.lat,a.lon,4,'rgba(255,152,0,0.7)','rgba(255,152,0,0.3)',ev=>showPopup(ev,a.event,a.headline||'','NOAA/NWS'),2)});
// EPA RadNet
(D.epa?.stations||[]).forEach(s=>{addPt(s.lat,s.lon,3,'rgba(205,220,57,0.6)','rgba(205,220,57,0.2)',ev=>showPopup(ev,'RadNet: '+s.location,`${s.analyte||'--'}: ${s.result||'--'} ${s.unit||''}`,'EPA'),3)});
// Space stations
(D.space?.stationPositions||[]).forEach(s=>{
const g=addPt(s.lat,s.lon,5,'rgba(255,255,255,0.9)','rgba(255,255,255,0.4)',ev=>showPopup(ev,s.name,'Orbital position estimate','Space Station'),1);
if(g) g.append('text').attr('class','marker-label').attr('x',8).attr('y',3).attr('fill','rgba(255,255,255,0.7)').attr('font-size','8px').attr('font-family','var(--mono)').text(s.name.split('(')[0].trim());
});
// GDELT geo events
(D.gdelt?.geoPoints||[]).forEach(g=>{addPt(g.lat,g.lon,2.5,'rgba(100,149,237,0.5)','rgba(100,149,237,0.2)',ev=>showPopup(ev,'GDELT Event',g.name||'','GDELT · '+g.count+' reports'),3)});
// ACLED
(D.acled?.deadliestEvents||[]).filter(e=>e.lat&&e.lon).forEach(e=>{
const[x,y]=proj([e.lon,e.lat]);if(!x||!y)return;
const r=Math.max(4,Math.min(14,2+Math.log2(Math.max(e.fatalities,1))*1.5));
const g=mg.append('g').attr('transform',`translate(${x},${y})`).style('cursor','pointer')
const g=mg.append('g').attr('transform',`translate(${x},${y})`).style('cursor','pointer').attr('data-priority',1)
.on('click',ev=>{ev.stopPropagation();showPopup(ev,e.type||'CONFLICT',`${e.fatalities} fatalities<br>${e.location}, ${e.country}`,'ACLED')});
g.append('circle').attr('class','conflict-ring marker-circle').attr('r',r).attr('data-base-r',r).attr('fill','none').attr('stroke','rgba(255,120,80,0.7)').attr('stroke-width',1.5);
g.append('circle').attr('r',r*0.4).attr('fill','rgba(255,120,80,0.3)');
});
// Flight corridors
const airCoordsFlight=[{lat:30,lon:44},{lat:24,lon:120},{lat:49,lon:32},{lat:57,lon:24},{lat:14,lon:114},{lat:37,lon:127}];
const airCoordsFlight=[{lat:30,lon:44},{lat:24,lon:120},{lat:49,lon:32},{lat:57,lon:24},{lat:14,lon:114},{lat:37,lon:127},{lat:25,lon:-80},{lat:4,lon:2},{lat:-34,lon:18},{lat:10,lon:51}];
const hubs=[{lat:40.6,lon:-73.8},{lat:51.5,lon:-0.5},{lat:25.3,lon:55.4},{lat:1.4,lon:103.8},{lat:-33.9,lon:151.2},{lat:-23.4,lon:-46.5}];
const cG=flatG.append('g').attr('class','corridors-layer');
for(let i=0;i<D.air.length;i++){for(let j=i+1;j<D.air.length;j++){
@@ -1202,9 +1291,10 @@ function init(){
}
document.addEventListener('DOMContentLoaded', () => {
const isServer = location.protocol !== 'file:';
const hasInlineData = !!(D && D.meta);
const canProbeApi = location.protocol !== 'file:';
if (isServer) {
if (canProbeApi && !hasInlineData) {
// Server mode: always fetch live data from API (ignore any stale inline D)
fetch('/api/data')
.then(r => r.json())
@@ -1213,7 +1303,7 @@ document.addEventListener('DOMContentLoaded', () => {
// Should not reach here — server routes to loading.html when no data
if (D && D.meta) { init(); connectSSE(); }
});
} else if (D && D.meta) {
} else if (hasInlineData) {
// File mode: use inline data
init();
}

View File

@@ -1,4 +1,3 @@
version: '3.8'
services:
crucix:
build: .

View File

@@ -2,14 +2,18 @@
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';
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';
/**
* Create an LLM provider based on config.
@@ -26,10 +30,14 @@ 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':
return new CodexProvider({ model });
case 'minimax':
return new MiniMaxProvider({ apiKey, model });
default:
console.warn(`[LLM] Unknown provider "${provider}". LLM features disabled.`);
return null;

51
lib/llm/minimax.mjs Normal file
View File

@@ -0,0 +1,51 @@
// MiniMax Provider — raw fetch, no SDK
// Uses MiniMax's OpenAI-compatible Chat Completions API
import { LLMProvider } from './provider.mjs';
export class MiniMaxProvider extends LLMProvider {
constructor(config) {
super(config);
this.name = 'minimax';
this.apiKey = config.apiKey;
this.model = config.model || 'MiniMax-M2.5';
}
get isConfigured() { return !!this.apiKey; }
async complete(systemPrompt, userMessage, opts = {}) {
const res = await fetch('https://api.minimax.io/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(`MiniMax 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,
};
}
}

52
lib/llm/openrouter.mjs Normal file
View File

@@ -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,
};
}
}

332
package-lock.json generated
View File

@@ -7,12 +7,221 @@
"": {
"name": "crucix",
"version": "2.0.0",
"license": "ISC",
"license": "AGPL-3.0-only",
"dependencies": {
"express": "^5.1.0"
},
"engines": {
"node": ">=22"
"node": ">=22",
"npm": ">=10"
},
"optionalDependencies": {
"discord.js": "^14.25.1"
}
},
"node_modules/@discordjs/builders": {
"version": "1.13.1",
"resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.13.1.tgz",
"integrity": "sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@discordjs/formatters": "^0.6.2",
"@discordjs/util": "^1.2.0",
"@sapphire/shapeshift": "^4.0.0",
"discord-api-types": "^0.38.33",
"fast-deep-equal": "^3.1.3",
"ts-mixer": "^6.0.4",
"tslib": "^2.6.3"
},
"engines": {
"node": ">=16.11.0"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/collection": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz",
"integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=16.11.0"
}
},
"node_modules/@discordjs/formatters": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz",
"integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"discord-api-types": "^0.38.33"
},
"engines": {
"node": ">=16.11.0"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/rest": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.0.tgz",
"integrity": "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@discordjs/collection": "^2.1.1",
"@discordjs/util": "^1.1.1",
"@sapphire/async-queue": "^1.5.3",
"@sapphire/snowflake": "^3.5.3",
"@vladfrangu/async_event_emitter": "^2.4.6",
"discord-api-types": "^0.38.16",
"magic-bytes.js": "^1.10.0",
"tslib": "^2.6.3",
"undici": "6.21.3"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/rest/node_modules/@discordjs/collection": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz",
"integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/util": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz",
"integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"discord-api-types": "^0.38.33"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/ws": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz",
"integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@discordjs/collection": "^2.1.0",
"@discordjs/rest": "^2.5.1",
"@discordjs/util": "^1.1.0",
"@sapphire/async-queue": "^1.5.2",
"@types/ws": "^8.5.10",
"@vladfrangu/async_event_emitter": "^2.2.4",
"discord-api-types": "^0.38.1",
"tslib": "^2.6.2",
"ws": "^8.17.0"
},
"engines": {
"node": ">=16.11.0"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/ws/node_modules/@discordjs/collection": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz",
"integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@sapphire/async-queue": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz",
"integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=v14.0.0",
"npm": ">=7.0.0"
}
},
"node_modules/@sapphire/shapeshift": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz",
"integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==",
"license": "MIT",
"optional": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"lodash": "^4.17.21"
},
"engines": {
"node": ">=v16"
}
},
"node_modules/@sapphire/snowflake": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz",
"integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=v14.0.0",
"npm": ">=7.0.0"
}
},
"node_modules/@types/node": {
"version": "25.5.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
"license": "MIT",
"optional": true,
"dependencies": {
"undici-types": "~7.18.0"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"optional": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@vladfrangu/async_event_emitter": {
"version": "2.4.7",
"resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz",
"integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=v14.0.0",
"npm": ">=7.0.0"
}
},
"node_modules/accepts": {
@@ -156,6 +365,44 @@
"node": ">= 0.8"
}
},
"node_modules/discord-api-types": {
"version": "0.38.42",
"resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.42.tgz",
"integrity": "sha512-qs1kya7S84r5RR8m9kgttywGrmmoHaRifU1askAoi+wkoSefLpZP6aGXusjNw5b0jD3zOg3LTwUa3Tf2iHIceQ==",
"license": "MIT",
"optional": true,
"workspaces": [
"scripts/actions/documentation"
]
},
"node_modules/discord.js": {
"version": "14.25.1",
"resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.25.1.tgz",
"integrity": "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@discordjs/builders": "^1.13.0",
"@discordjs/collection": "1.5.3",
"@discordjs/formatters": "^0.6.2",
"@discordjs/rest": "^2.6.0",
"@discordjs/util": "^1.2.0",
"@discordjs/ws": "^1.2.3",
"@sapphire/snowflake": "3.5.3",
"discord-api-types": "^0.38.33",
"fast-deep-equal": "3.1.3",
"lodash.snakecase": "4.1.1",
"magic-bytes.js": "^1.10.0",
"tslib": "^2.6.3",
"undici": "6.21.3"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -273,6 +520,13 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT",
"optional": true
},
"node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
@@ -451,6 +705,27 @@
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT"
},
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT",
"optional": true
},
"node_modules/lodash.snakecase": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz",
"integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==",
"license": "MIT",
"optional": true
},
"node_modules/magic-bytes.js": {
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz",
"integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==",
"license": "MIT",
"optional": true
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -788,6 +1063,20 @@
"node": ">=0.6"
}
},
"node_modules/ts-mixer": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz",
"integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==",
"license": "MIT",
"optional": true
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"optional": true
},
"node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
@@ -802,6 +1091,23 @@
"node": ">= 0.6"
}
},
"node_modules/undici": {
"version": "7.24.4",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.4.tgz",
"integrity": "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"license": "MIT",
"optional": true
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -825,6 +1131,28 @@
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

View File

@@ -1,7 +1,7 @@
{
"name": "crucix",
"version": "2.0.0",
"description": "Local intelligence engine 26 OSINT sources, live dashboard, auto-refresh, optional LLM layer.",
"description": "Local intelligence engine - 27 OSINT sources, live dashboard, public demo at crucix.live, auto-refresh, optional LLM layer.",
"type": "module",
"scripts": {
"start": "node server.mjs",
@@ -14,7 +14,12 @@
"clean": "node scripts/clean.mjs",
"fresh-start": "npm run clean && npm start"
},
"keywords": ["osint", "intelligence", "dashboard", "geopolitical"],
"keywords": [
"osint",
"intelligence",
"dashboard",
"geopolitical"
],
"author": "Crucix",
"license": "AGPL-3.0-only",
"engines": {
@@ -25,6 +30,7 @@
"express": "^5.1.0"
},
"optionalDependencies": {
"discord.js": "^14.25.0"
}
"discord.js": "^14.25.1" },
"overrides": {
"undici": "^7.24.4" }
}

View File

@@ -0,0 +1,30 @@
// MiniMax provider — integration test (calls real API)
// Requires MINIMAX_API_KEY environment variable
// Run: MINIMAX_API_KEY=sk-... node --test test/llm-minimax-integration.test.mjs
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { MiniMaxProvider } from '../lib/llm/minimax.mjs';
const API_KEY = process.env.MINIMAX_API_KEY;
describe('MiniMax integration', { skip: !API_KEY && 'MINIMAX_API_KEY not set' }, () => {
it('should complete a prompt with MiniMax-M2.5', async () => {
const provider = new MiniMaxProvider({ apiKey: API_KEY, model: 'MiniMax-M2.5' });
assert.equal(provider.isConfigured, true);
const result = await provider.complete(
'You are a helpful assistant. Respond in exactly one sentence.',
'What is 2+2?',
{ maxTokens: 128, timeout: 30000 }
);
assert.ok(result.text.length > 0, 'Response text should not be empty');
assert.ok(result.usage.inputTokens > 0, 'Should report input tokens');
assert.ok(result.usage.outputTokens > 0, 'Should report output tokens');
assert.ok(result.model, 'Should report model name');
console.log(` Response: ${result.text}`);
console.log(` Tokens: ${result.usage.inputTokens} in / ${result.usage.outputTokens} out`);
console.log(` Model: ${result.model}`);
});
});

144
test/llm-minimax.test.mjs Normal file
View File

@@ -0,0 +1,144 @@
// MiniMax 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 { MiniMaxProvider } from '../lib/llm/minimax.mjs';
import { createLLMProvider } from '../lib/llm/index.mjs';
// ─── Unit Tests ───
describe('MiniMaxProvider', () => {
it('should set defaults correctly', () => {
const provider = new MiniMaxProvider({ apiKey: 'sk-test' });
assert.equal(provider.name, 'minimax');
assert.equal(provider.model, 'MiniMax-M2.5');
assert.equal(provider.isConfigured, true);
});
it('should accept custom model', () => {
const provider = new MiniMaxProvider({ apiKey: 'sk-test', model: 'MiniMax-M2.5-highspeed' });
assert.equal(provider.model, 'MiniMax-M2.5-highspeed');
});
it('should report not configured without API key', () => {
const provider = new MiniMaxProvider({});
assert.equal(provider.isConfigured, false);
});
it('should throw on API error', async () => {
const provider = new MiniMaxProvider({ 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, /MiniMax API 401/);
return true;
}
);
} finally {
globalThis.fetch = originalFetch;
}
});
it('should parse successful response', async () => {
const provider = new MiniMaxProvider({ apiKey: 'sk-test' });
const mockResponse = {
choices: [{ message: { content: 'Hello from MiniMax' } }],
usage: { prompt_tokens: 10, completion_tokens: 5 },
model: 'MiniMax-M2.5',
};
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 MiniMax');
assert.equal(result.usage.inputTokens, 10);
assert.equal(result.usage.outputTokens, 5);
assert.equal(result.model, 'MiniMax-M2.5');
} finally {
globalThis.fetch = originalFetch;
}
});
it('should send correct request format', async () => {
const provider = new MiniMaxProvider({ apiKey: 'sk-test-key', model: 'MiniMax-M2.5' });
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: 'MiniMax-M2.5',
}),
});
});
try {
await provider.complete('system prompt', 'user message', { maxTokens: 2048 });
assert.equal(capturedUrl, 'https://api.minimax.io/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, 'MiniMax-M2.5');
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 MiniMaxProvider({ 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 — minimax', () => {
it('should create MiniMaxProvider for provider=minimax', () => {
const provider = createLLMProvider({ provider: 'minimax', apiKey: 'sk-test', model: null });
assert.ok(provider instanceof MiniMaxProvider);
assert.equal(provider.name, 'minimax');
assert.equal(provider.isConfigured, true);
});
it('should be case-insensitive', () => {
const provider = createLLMProvider({ provider: 'MiniMax', apiKey: 'sk-test', model: null });
assert.ok(provider instanceof MiniMaxProvider);
});
it('should return null for empty provider', () => {
const provider = createLLMProvider({ provider: null, apiKey: 'sk-test', model: null });
assert.equal(provider, null);
});
});

View File

@@ -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');
});
});

View File

@@ -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;
}
});
});