Add NexusAI: universal MCP service (TTS/STT/image/video/music/web-search)
Monorepo-lite with shared @nexusai/core and @nexusai/mcp-server (stdio). Provider-agnostic tools with explicit provider selection; NordRouter adapter (media generate/poll/download, estimate, upload, models) and search via perplexity/sonar. Local STT via faster-whisper uv sidecar. SQLite journal of every generation for usage stats and future admin. 10 MCP tools.
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Unified error type for NexusAI. Every provider adapter should throw NexusError
|
||||
* so the MCP layer can render a consistent { code, message } shape.
|
||||
*/
|
||||
export type NexusErrorCode =
|
||||
| 'CONFIG'
|
||||
| 'AUTH'
|
||||
| 'BAD_REQUEST'
|
||||
| 'NOT_FOUND'
|
||||
| 'RATE_LIMIT'
|
||||
| 'INSUFFICIENT_BALANCE'
|
||||
| 'PROVIDER'
|
||||
| 'TIMEOUT'
|
||||
| 'UNSUPPORTED'
|
||||
| 'IO'
|
||||
| 'UNKNOWN';
|
||||
|
||||
export class NexusError extends Error {
|
||||
readonly code: NexusErrorCode;
|
||||
readonly details?: unknown;
|
||||
|
||||
constructor(code: NexusErrorCode, message: string, details?: unknown) {
|
||||
super(message);
|
||||
this.name = 'NexusError';
|
||||
this.code = code;
|
||||
this.details = details;
|
||||
}
|
||||
|
||||
static from(error: unknown, fallbackCode: NexusErrorCode = 'UNKNOWN'): NexusError {
|
||||
if (error instanceof NexusError) return error;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return new NexusError(fallbackCode, message);
|
||||
}
|
||||
}
|
||||
|
||||
/** Map an HTTP status code to a NexusErrorCode. */
|
||||
export function codeFromHttpStatus(status: number): NexusErrorCode {
|
||||
switch (status) {
|
||||
case 400:
|
||||
return 'BAD_REQUEST';
|
||||
case 401:
|
||||
case 403:
|
||||
return 'AUTH';
|
||||
case 402:
|
||||
return 'INSUFFICIENT_BALANCE';
|
||||
case 404:
|
||||
return 'NOT_FOUND';
|
||||
case 429:
|
||||
return 'RATE_LIMIT';
|
||||
default:
|
||||
return status >= 500 ? 'PROVIDER' : 'UNKNOWN';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { fetch, ProxyAgent, type RequestInit, type Response } from 'undici';
|
||||
import { NexusError, codeFromHttpStatus } from './errors.js';
|
||||
import { sleep } from './polling.js';
|
||||
|
||||
function getProxyUrl(): string | undefined {
|
||||
return (
|
||||
process.env.NEXUS_HTTP_PROXY ||
|
||||
process.env.HTTPS_PROXY ||
|
||||
process.env.https_proxy ||
|
||||
process.env.HTTP_PROXY ||
|
||||
process.env.http_proxy
|
||||
);
|
||||
}
|
||||
|
||||
function isRetryableError(error: unknown): boolean {
|
||||
if (error instanceof Error) {
|
||||
const msg = error.message.toLowerCase();
|
||||
return (
|
||||
msg.includes('timeout') ||
|
||||
msg.includes('econnreset') ||
|
||||
msg.includes('socket') ||
|
||||
msg.includes('fetch failed') ||
|
||||
msg.includes('und_err')
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Simple sliding-window rate limiter. */
|
||||
export class RateLimiter {
|
||||
private timestamps: number[] = [];
|
||||
constructor(private readonly maxRequests: number, private readonly windowMs: number) {}
|
||||
|
||||
async acquire(): Promise<void> {
|
||||
while (true) {
|
||||
const now = Date.now();
|
||||
this.timestamps = this.timestamps.filter((ts) => now - ts < this.windowMs);
|
||||
if (this.timestamps.length < this.maxRequests) {
|
||||
this.timestamps.push(now);
|
||||
return;
|
||||
}
|
||||
const oldest = this.timestamps[0];
|
||||
await sleep(Math.max(0, this.windowMs - (now - oldest)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface HttpClientOptions {
|
||||
baseUrl: string;
|
||||
defaultHeaders?: Record<string, string>;
|
||||
timeoutMs?: number;
|
||||
maxRetries?: number;
|
||||
rateLimiter?: RateLimiter;
|
||||
}
|
||||
|
||||
export interface RequestOptions {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
path: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: unknown;
|
||||
timeoutMs?: number;
|
||||
/** Skip JSON parsing and return raw Response (for binary downloads). */
|
||||
raw?: boolean;
|
||||
}
|
||||
|
||||
export class HttpClient {
|
||||
private readonly proxyAgent?: ProxyAgent;
|
||||
|
||||
constructor(private readonly opts: HttpClientOptions) {
|
||||
const proxyUrl = getProxyUrl();
|
||||
if (proxyUrl) this.proxyAgent = new ProxyAgent(proxyUrl);
|
||||
}
|
||||
|
||||
async requestRaw(options: RequestOptions): Promise<Response> {
|
||||
if (this.opts.rateLimiter) await this.opts.rateLimiter.acquire();
|
||||
|
||||
const url = options.path.startsWith('http')
|
||||
? options.path
|
||||
: `${this.opts.baseUrl}${options.path}`;
|
||||
const method = options.method ?? 'GET';
|
||||
const timeoutMs = options.timeoutMs ?? this.opts.timeoutMs ?? 60_000;
|
||||
const maxRetries = this.opts.maxRetries ?? 2;
|
||||
|
||||
const headers: Record<string, string> = { ...this.opts.defaultHeaders, ...options.headers };
|
||||
let bodyStr: string | undefined;
|
||||
if (options.body !== undefined) {
|
||||
bodyStr = typeof options.body === 'string' ? options.body : JSON.stringify(options.body);
|
||||
if (!headers['Content-Type'] && !headers['content-type']) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
}
|
||||
|
||||
let lastError: Error | undefined;
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const init: RequestInit & { dispatcher?: ProxyAgent } = {
|
||||
method,
|
||||
headers,
|
||||
body: bodyStr,
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
};
|
||||
if (this.proxyAgent) init.dispatcher = this.proxyAgent;
|
||||
|
||||
const response = await fetch(url, init);
|
||||
|
||||
if ((response.status === 429 || response.status >= 500) && attempt < maxRetries) {
|
||||
await sleep(Math.min(1000 * 2 ** attempt, 8000));
|
||||
continue;
|
||||
}
|
||||
return response;
|
||||
} catch (error: unknown) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
if (isRetryableError(lastError) && attempt < maxRetries) {
|
||||
await sleep(Math.min(1000 * 2 ** attempt, 8000));
|
||||
continue;
|
||||
}
|
||||
if (lastError.name === 'TimeoutError' || lastError.message.includes('aborted')) {
|
||||
throw new NexusError('TIMEOUT', `Request to ${url} timed out after ${timeoutMs}ms`);
|
||||
}
|
||||
throw NexusError.from(lastError, 'PROVIDER');
|
||||
}
|
||||
}
|
||||
throw NexusError.from(lastError ?? new Error('Request failed'), 'PROVIDER');
|
||||
}
|
||||
|
||||
async requestJson<T = unknown>(options: RequestOptions): Promise<T> {
|
||||
const response = await this.requestRaw(options);
|
||||
const text = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
let message = `HTTP ${response.status}`;
|
||||
try {
|
||||
const parsed = JSON.parse(text) as { message?: string; error?: { message?: string }; msg?: string };
|
||||
message = parsed.message || parsed.error?.message || parsed.msg || message;
|
||||
} catch {
|
||||
if (text) message = `${message}: ${text.slice(0, 300)}`;
|
||||
}
|
||||
throw new NexusError(codeFromHttpStatus(response.status), message, { status: response.status });
|
||||
}
|
||||
|
||||
if (!text) return undefined as T;
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch {
|
||||
throw new NexusError('PROVIDER', `Invalid JSON response: ${text.slice(0, 300)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* MCP servers communicate over stdio, so ALL logs must go to stderr.
|
||||
* Never write logs to stdout — it corrupts the JSON-RPC stream.
|
||||
*/
|
||||
type Level = 'debug' | 'info' | 'warn' | 'error';
|
||||
|
||||
const LEVELS: Record<Level, number> = { debug: 10, info: 20, warn: 30, error: 40 };
|
||||
|
||||
function currentThreshold(): number {
|
||||
const env = (process.env.NEXUS_LOG_LEVEL || 'info').toLowerCase() as Level;
|
||||
return LEVELS[env] ?? LEVELS.info;
|
||||
}
|
||||
|
||||
function emit(level: Level, scope: string, message: string, extra?: unknown): void {
|
||||
if (LEVELS[level] < currentThreshold()) return;
|
||||
const ts = new Date().toISOString();
|
||||
let line = `[${ts}] ${level.toUpperCase()} (${scope}) ${message}`;
|
||||
if (extra !== undefined) {
|
||||
try {
|
||||
line += ` ${typeof extra === 'string' ? extra : JSON.stringify(extra)}`;
|
||||
} catch {
|
||||
line += ' [unserializable extra]';
|
||||
}
|
||||
}
|
||||
process.stderr.write(line + '\n');
|
||||
}
|
||||
|
||||
export interface Logger {
|
||||
debug(message: string, extra?: unknown): void;
|
||||
info(message: string, extra?: unknown): void;
|
||||
warn(message: string, extra?: unknown): void;
|
||||
error(message: string, extra?: unknown): void;
|
||||
}
|
||||
|
||||
export function createLogger(scope: string): Logger {
|
||||
return {
|
||||
debug: (m, e) => emit('debug', scope, m, e),
|
||||
info: (m, e) => emit('info', scope, m, e),
|
||||
warn: (m, e) => emit('warn', scope, m, e),
|
||||
error: (m, e) => emit('error', scope, m, e),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NexusError } from './errors.js';
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export interface PollOptions<T> {
|
||||
/** Called every interval. Return { done: true, value } to stop. */
|
||||
poll: () => Promise<{ done: boolean; value?: T; failed?: boolean; error?: string }>;
|
||||
intervalMs?: number;
|
||||
timeoutMs?: number;
|
||||
onTick?: (attempt: number, elapsedMs: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic polling loop with wall-clock timeout. Used for async media jobs.
|
||||
*/
|
||||
export async function pollUntilDone<T>(options: PollOptions<T>): Promise<T> {
|
||||
const intervalMs = options.intervalMs ?? 3000;
|
||||
const timeoutMs = options.timeoutMs ?? 10 * 60 * 1000;
|
||||
const start = Date.now();
|
||||
let attempt = 0;
|
||||
|
||||
while (true) {
|
||||
attempt += 1;
|
||||
const result = await options.poll();
|
||||
options.onTick?.(attempt, Date.now() - start);
|
||||
|
||||
if (result.failed) {
|
||||
throw new NexusError('PROVIDER', result.error || 'Job failed');
|
||||
}
|
||||
if (result.done) {
|
||||
return result.value as T;
|
||||
}
|
||||
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
throw new NexusError('TIMEOUT', `Polling timed out after ${Math.round(timeoutMs / 1000)}s`);
|
||||
}
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user