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:
OpenCode
2026-08-17 17:06:47 +07:00
parent 82087be1b3
commit 02fc536e85
32 changed files with 3861 additions and 0 deletions
@@ -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';
}
}
+148
View File
@@ -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);
}
}