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,139 @@
|
||||
import { spawn } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { fetch, ProxyAgent } from 'undici';
|
||||
import type { SttConfig } from '../../config.js';
|
||||
import { NexusError } from '../../shared/errors.js';
|
||||
import { createLogger } from '../../shared/logger.js';
|
||||
import type { SttInput, SttProvider, SttResult } from '../types.js';
|
||||
|
||||
const log = createLogger('local-stt');
|
||||
|
||||
interface SidecarResponse {
|
||||
ok: boolean;
|
||||
text?: string;
|
||||
language?: string;
|
||||
duration?: number;
|
||||
segments?: Array<{ start: number; end: number; text: string }>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Local speech-to-text via a faster-whisper Python sidecar launched with `uv`.
|
||||
* The sidecar reads a single JSON request on argv and prints a JSON response.
|
||||
* Architecture allows swapping in cloud STT adapters later without MCP changes.
|
||||
*/
|
||||
export class LocalWhisperProvider implements SttProvider {
|
||||
readonly name = 'local';
|
||||
|
||||
constructor(private readonly cfg: SttConfig) {}
|
||||
|
||||
private resolveSidecar(): string {
|
||||
if (this.cfg.sidecarPath) return this.cfg.sidecarPath;
|
||||
// packages/core/dist/providers/local-stt/ -> repo/sidecar/stt_server.py
|
||||
const guess = path.resolve(__dirname, '../../../../../sidecar/stt_server.py');
|
||||
return guess;
|
||||
}
|
||||
|
||||
private async materializeAudio(input: SttInput): Promise<{ file: string; cleanup: boolean }> {
|
||||
if (input.audioPath) {
|
||||
if (!fs.existsSync(input.audioPath)) {
|
||||
throw new NexusError('IO', `Audio file not found: ${input.audioPath}`);
|
||||
}
|
||||
return { file: input.audioPath, cleanup: false };
|
||||
}
|
||||
if (input.audioBase64) {
|
||||
const m = /^data:([^;]+);base64,(.*)$/s.exec(input.audioBase64);
|
||||
const b64 = m ? m[2] : input.audioBase64;
|
||||
const tmp = path.join(os.tmpdir(), `nexus-stt-${Date.now()}-${Math.random().toString(36).slice(2)}.audio`);
|
||||
fs.writeFileSync(tmp, Buffer.from(b64, 'base64'));
|
||||
return { file: tmp, cleanup: true };
|
||||
}
|
||||
if (input.audioUrl) {
|
||||
const proxy = process.env.NEXUS_HTTP_PROXY || process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
|
||||
const res = await fetch(input.audioUrl, proxy ? { dispatcher: new ProxyAgent(proxy) } : {});
|
||||
if (!res.ok) throw new NexusError('IO', `Failed to download audio (${res.status})`);
|
||||
const tmp = path.join(os.tmpdir(), `nexus-stt-${Date.now()}-${Math.random().toString(36).slice(2)}.audio`);
|
||||
fs.writeFileSync(tmp, Buffer.from(await res.arrayBuffer()));
|
||||
return { file: tmp, cleanup: true };
|
||||
}
|
||||
throw new NexusError('BAD_REQUEST', 'Provide one of: audioPath, audioUrl, audioBase64');
|
||||
}
|
||||
|
||||
async transcribe(input: SttInput): Promise<SttResult> {
|
||||
const { file, cleanup } = await this.materializeAudio(input);
|
||||
const sidecar = this.resolveSidecar();
|
||||
|
||||
const request = {
|
||||
audio: file,
|
||||
model: input.model || this.cfg.model,
|
||||
device: this.cfg.device,
|
||||
compute_type: this.cfg.computeType,
|
||||
language: input.language,
|
||||
};
|
||||
|
||||
// With uv: `uv run --with faster-whisper python sidecar/stt_server.py <json>`
|
||||
const usingUv = /(^|\/)uv$/.test(this.cfg.pythonCmd) || this.cfg.pythonCmd === 'uv';
|
||||
const sidecarDir = path.dirname(sidecar);
|
||||
const args = usingUv
|
||||
? ['run', sidecar, JSON.stringify(request)]
|
||||
: [sidecar, JSON.stringify(request)];
|
||||
|
||||
log.debug('spawning stt sidecar', { cmd: this.cfg.pythonCmd, model: request.model });
|
||||
|
||||
try {
|
||||
const response = await this.runSidecar(this.cfg.pythonCmd, args, sidecarDir);
|
||||
if (!response.ok) {
|
||||
throw new NexusError('PROVIDER', response.error || 'STT sidecar failed');
|
||||
}
|
||||
return {
|
||||
provider: this.name,
|
||||
text: response.text || '',
|
||||
language: response.language,
|
||||
durationSec: response.duration,
|
||||
segments: response.segments,
|
||||
};
|
||||
} finally {
|
||||
if (cleanup) {
|
||||
try {
|
||||
fs.unlinkSync(file);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private runSidecar(cmd: string, args: string[], cwd: string): Promise<SidecarResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Forward HF mirror / offline settings so the sidecar can locate models
|
||||
// in restricted networks.
|
||||
const env = {
|
||||
...process.env,
|
||||
...(process.env.HF_ENDPOINT ? { HF_ENDPOINT: process.env.HF_ENDPOINT } : {}),
|
||||
};
|
||||
const child = spawn(cmd, args, { cwd, env });
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.on('data', (d) => (stdout += d.toString()));
|
||||
child.stderr.on('data', (d) => (stderr += d.toString()));
|
||||
child.on('error', (err) =>
|
||||
reject(new NexusError('CONFIG', `Failed to launch STT sidecar (${cmd}): ${err.message}`))
|
||||
);
|
||||
child.on('close', (code) => {
|
||||
if (code !== 0 && !stdout.trim()) {
|
||||
reject(new NexusError('PROVIDER', `STT sidecar exited ${code}: ${stderr.slice(-500)}`));
|
||||
return;
|
||||
}
|
||||
// The sidecar prints exactly one JSON line on stdout.
|
||||
const line = stdout.trim().split('\n').filter(Boolean).pop() || '';
|
||||
try {
|
||||
resolve(JSON.parse(line) as SidecarResponse);
|
||||
} catch {
|
||||
reject(new NexusError('PROVIDER', `Invalid STT sidecar output: ${stdout.slice(-500)} | stderr: ${stderr.slice(-300)}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { HttpClient, RateLimiter } from '../../shared/http.js';
|
||||
import { NexusError } from '../../shared/errors.js';
|
||||
|
||||
/**
|
||||
* Low-level NordRouter HTTP client. Shared by media, search, upload and models.
|
||||
* Auth: `Authorization: Bearer sk-...`. Base: https://nordrouter.com
|
||||
*/
|
||||
export class NordRouterClient {
|
||||
readonly http: HttpClient;
|
||||
readonly baseUrl: string;
|
||||
|
||||
constructor(apiKey: string | undefined, baseUrl: string) {
|
||||
if (!apiKey) {
|
||||
throw new NexusError('CONFIG', 'NORDROUTER_API_KEY is not set');
|
||||
}
|
||||
this.baseUrl = baseUrl;
|
||||
this.http = new HttpClient({
|
||||
baseUrl,
|
||||
defaultHeaders: { Authorization: `Bearer ${apiKey}` },
|
||||
timeoutMs: 60_000,
|
||||
maxRetries: 2,
|
||||
// NordRouter rate-limits per key; keep it modest and let 429 retries handle bursts.
|
||||
rateLimiter: new RateLimiter(30, 10_000),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { NordRouterConfig } from '../../config.js';
|
||||
import type { MediaStorage } from '../../media/storage.js';
|
||||
import { NordRouterClient } from './client.js';
|
||||
import { NordRouterMediaProvider } from './media.js';
|
||||
import { NordRouterSearchProvider } from './search.js';
|
||||
|
||||
export { NordRouterClient } from './client.js';
|
||||
export { NordRouterMediaProvider } from './media.js';
|
||||
export { NordRouterSearchProvider } from './search.js';
|
||||
|
||||
export interface NordRouterFactoryOptions {
|
||||
config: NordRouterConfig;
|
||||
storage: MediaStorage;
|
||||
save: boolean;
|
||||
}
|
||||
|
||||
export function createNordRouterProviders(opts: NordRouterFactoryOptions): {
|
||||
media: NordRouterMediaProvider;
|
||||
search: NordRouterSearchProvider;
|
||||
} {
|
||||
const client = new NordRouterClient(opts.config.apiKey, opts.config.baseUrl);
|
||||
return {
|
||||
media: new NordRouterMediaProvider(client, { save: opts.save, storage: opts.storage }),
|
||||
search: new NordRouterSearchProvider(client),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import * as fs from 'fs';
|
||||
import { NexusError } from '../../shared/errors.js';
|
||||
import { createLogger } from '../../shared/logger.js';
|
||||
import { pollUntilDone } from '../../shared/polling.js';
|
||||
import { MediaStorage } from '../../media/storage.js';
|
||||
import type {
|
||||
Capability,
|
||||
EstimateInput,
|
||||
MediaProvider,
|
||||
MediaRequest,
|
||||
MediaResult,
|
||||
ModelInfo,
|
||||
UploadInput,
|
||||
UploadResult,
|
||||
} from '../types.js';
|
||||
import { NordRouterClient } from './client.js';
|
||||
|
||||
const log = createLogger('nordrouter:media');
|
||||
|
||||
interface JobCreateResponse {
|
||||
id: string;
|
||||
model: string;
|
||||
status: string;
|
||||
poll?: string;
|
||||
webhook?: string;
|
||||
}
|
||||
|
||||
interface JobStatusResponse {
|
||||
id: string;
|
||||
status: 'processing' | 'done' | 'failed';
|
||||
result_url: string | null;
|
||||
result_url_2?: string | null;
|
||||
results?: Array<{ index: number; name: string; url: string }>;
|
||||
cost_usd?: number | null;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
interface ModelsResponse {
|
||||
models: Array<{ id: string; type?: string; label?: string; est_usd?: number; fields?: unknown }>;
|
||||
retention_days?: number;
|
||||
}
|
||||
|
||||
/** Timeout per capability — images are quick, video/music take longer. */
|
||||
const TIMEOUT_MS: Record<Capability, number> = {
|
||||
image: 3 * 60 * 1000,
|
||||
tts: 3 * 60 * 1000,
|
||||
music: 8 * 60 * 1000,
|
||||
video: 15 * 60 * 1000,
|
||||
stt: 3 * 60 * 1000,
|
||||
search: 60 * 1000,
|
||||
};
|
||||
|
||||
const PREFIX: Record<Capability, string> = {
|
||||
image: 'image',
|
||||
tts: 'audio',
|
||||
music: 'music',
|
||||
video: 'video',
|
||||
stt: 'stt',
|
||||
search: 'search',
|
||||
};
|
||||
|
||||
export interface NordRouterMediaOptions {
|
||||
save: boolean;
|
||||
storage?: MediaStorage;
|
||||
pollIntervalMs?: number;
|
||||
}
|
||||
|
||||
export class NordRouterMediaProvider implements MediaProvider {
|
||||
readonly name = 'nordrouter';
|
||||
readonly capabilities: Capability[] = ['tts', 'image', 'video', 'music'];
|
||||
|
||||
private modelsCache?: { at: number; models: ModelInfo[] };
|
||||
private readonly modelsTtlMs = 10 * 60 * 1000;
|
||||
|
||||
constructor(
|
||||
private readonly client: NordRouterClient,
|
||||
private readonly defaults: NordRouterMediaOptions
|
||||
) {}
|
||||
|
||||
async generate(capability: Capability, req: MediaRequest): Promise<MediaResult> {
|
||||
const created = await this.client.http.requestJson<JobCreateResponse>({
|
||||
method: 'POST',
|
||||
path: '/media/generate',
|
||||
body: { model: req.model, input: req.input },
|
||||
});
|
||||
|
||||
if (!created.id) {
|
||||
throw new NexusError('PROVIDER', 'NordRouter did not return a job id', created);
|
||||
}
|
||||
log.debug('job created', { id: created.id, model: req.model });
|
||||
|
||||
const final = await pollUntilDone<JobStatusResponse>({
|
||||
intervalMs: this.defaults.pollIntervalMs ?? 3000,
|
||||
timeoutMs: TIMEOUT_MS[capability],
|
||||
poll: async () => {
|
||||
const s = await this.client.http.requestJson<JobStatusResponse>({
|
||||
method: 'GET',
|
||||
path: `/media/job/${encodeURIComponent(created.id)}`,
|
||||
});
|
||||
if (s.status === 'failed') return { done: false, failed: true, error: s.error || 'job failed' };
|
||||
if (s.status === 'done') return { done: true, value: s };
|
||||
return { done: false };
|
||||
},
|
||||
});
|
||||
|
||||
const result: MediaResult = {
|
||||
provider: this.name,
|
||||
capability,
|
||||
model: req.model,
|
||||
status: 'done',
|
||||
resultUrl: final.result_url ?? undefined,
|
||||
resultUrl2: final.result_url_2 ?? undefined,
|
||||
costUsd: final.cost_usd ?? undefined,
|
||||
raw: final,
|
||||
};
|
||||
|
||||
if (this.defaults.save && this.defaults.storage && final.result_url) {
|
||||
const saved = await this.defaults.storage.downloadUrl(final.result_url, PREFIX[capability]);
|
||||
result.filePath = saved.filePath;
|
||||
result.mimeType = saved.mimeType;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async estimate(req: EstimateInput): Promise<{ usd: number }> {
|
||||
return this.client.http.requestJson<{ usd: number }>({
|
||||
method: 'POST',
|
||||
path: '/media/estimate',
|
||||
body: { model: req.model, input: req.input },
|
||||
});
|
||||
}
|
||||
|
||||
async upload(input: UploadInput): Promise<UploadResult> {
|
||||
if (!fs.existsSync(input.path)) {
|
||||
throw new NexusError('IO', `Upload file not found: ${input.path}`);
|
||||
}
|
||||
const buf = fs.readFileSync(input.path);
|
||||
const mime = input.mimeType || 'application/octet-stream';
|
||||
const name = input.name || input.path.split('/').pop() || 'file';
|
||||
const dataUrl = `data:${mime};base64,${buf.toString('base64')}`;
|
||||
const res = await this.client.http.requestJson<{ url?: string }>({
|
||||
method: 'POST',
|
||||
path: '/media/upload',
|
||||
body: { data: dataUrl, name },
|
||||
});
|
||||
return { ref: res, url: res.url };
|
||||
}
|
||||
|
||||
async listModels(): Promise<ModelInfo[]> {
|
||||
const now = Date.now();
|
||||
if (this.modelsCache && now - this.modelsCache.at < this.modelsTtlMs) {
|
||||
return this.modelsCache.models;
|
||||
}
|
||||
const res = await this.client.http.requestJson<ModelsResponse>({
|
||||
method: 'GET',
|
||||
path: '/media/models',
|
||||
});
|
||||
const models: ModelInfo[] = (res.models || []).map((m) => ({
|
||||
id: m.id,
|
||||
type: m.type,
|
||||
label: m.label,
|
||||
estUsd: m.est_usd,
|
||||
fields: m.fields,
|
||||
}));
|
||||
this.modelsCache = { at: now, models };
|
||||
return models;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { createLogger } from '../../shared/logger.js';
|
||||
import type { SearchInput, SearchProvider, SearchResult } from '../types.js';
|
||||
import { NordRouterClient } from './client.js';
|
||||
|
||||
const log = createLogger('nordrouter:search');
|
||||
|
||||
interface ChatResponse {
|
||||
choices: Array<{
|
||||
message: {
|
||||
content: string;
|
||||
annotations?: Array<{
|
||||
type: string;
|
||||
url_citation?: { url: string; title?: string };
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
usage?: { total_cost?: number };
|
||||
}
|
||||
|
||||
const DEFAULT_MODEL = 'perplexity/sonar-pro';
|
||||
|
||||
/**
|
||||
* Web search via NordRouter's OpenAI-compatible chat endpoint using
|
||||
* Perplexity sonar / search-augmented models. Citations arrive in
|
||||
* `message.annotations[].url_citation`.
|
||||
*/
|
||||
export class NordRouterSearchProvider implements SearchProvider {
|
||||
readonly name = 'nordrouter';
|
||||
|
||||
constructor(private readonly client: NordRouterClient) {}
|
||||
|
||||
async search(input: SearchInput): Promise<SearchResult> {
|
||||
const model = input.model || DEFAULT_MODEL;
|
||||
let query = input.query;
|
||||
if (input.recency) {
|
||||
query += `\n\n(Focus on information from the last ${input.recency}.)`;
|
||||
}
|
||||
|
||||
const res = await this.client.http.requestJson<ChatResponse>({
|
||||
method: 'POST',
|
||||
path: '/v1/chat/completions',
|
||||
body: { model, messages: [{ role: 'user', content: query }] },
|
||||
timeoutMs: 90_000,
|
||||
});
|
||||
|
||||
const choice = res.choices?.[0]?.message;
|
||||
const answer = choice?.content ?? '';
|
||||
const seen = new Set<string>();
|
||||
const citations: SearchResult['citations'] = [];
|
||||
let idx = 1;
|
||||
for (const a of choice?.annotations ?? []) {
|
||||
const c = a.url_citation;
|
||||
if (c?.url && !seen.has(c.url)) {
|
||||
seen.add(c.url);
|
||||
citations.push({ index: idx++, url: c.url, title: c.title });
|
||||
}
|
||||
}
|
||||
if (input.maxResults && citations.length > input.maxResults) {
|
||||
citations.length = input.maxResults;
|
||||
}
|
||||
|
||||
log.debug('search done', { model, citations: citations.length });
|
||||
return {
|
||||
provider: this.name,
|
||||
model,
|
||||
answer,
|
||||
citations,
|
||||
costUsd: res.usage?.total_cost,
|
||||
raw: res,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { NexusError } from '../shared/errors.js';
|
||||
import type { Capability, MediaProvider, SearchProvider, SttProvider } from './types.js';
|
||||
|
||||
/**
|
||||
* Registry maps a provider name + capability to the concrete adapter.
|
||||
* Adding RouterAI / GPTunnel / OpenRouter / cloud STT means registering a new
|
||||
* adapter here — no changes needed in the MCP tool layer.
|
||||
*/
|
||||
export class ProviderRegistry {
|
||||
private media = new Map<string, MediaProvider>();
|
||||
private search = new Map<string, SearchProvider>();
|
||||
private stt = new Map<string, SttProvider>();
|
||||
|
||||
registerMedia(provider: MediaProvider): void {
|
||||
this.media.set(provider.name, provider);
|
||||
}
|
||||
registerSearch(provider: SearchProvider): void {
|
||||
this.search.set(provider.name, provider);
|
||||
}
|
||||
registerStt(provider: SttProvider): void {
|
||||
this.stt.set(provider.name, provider);
|
||||
}
|
||||
|
||||
getMedia(name: string, capability: Capability): MediaProvider {
|
||||
const p = this.media.get(name);
|
||||
if (!p) {
|
||||
throw new NexusError(
|
||||
'UNSUPPORTED',
|
||||
`Unknown media provider "${name}". Available: ${[...this.media.keys()].join(', ') || 'none'}`
|
||||
);
|
||||
}
|
||||
if (!p.capabilities.includes(capability)) {
|
||||
throw new NexusError(
|
||||
'UNSUPPORTED',
|
||||
`Provider "${name}" does not support capability "${capability}"`
|
||||
);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
getSearch(name: string): SearchProvider {
|
||||
const p = this.search.get(name);
|
||||
if (!p) {
|
||||
throw new NexusError(
|
||||
'UNSUPPORTED',
|
||||
`Unknown search provider "${name}". Available: ${[...this.search.keys()].join(', ') || 'none'}`
|
||||
);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
getStt(name: string): SttProvider {
|
||||
const p = this.stt.get(name);
|
||||
if (!p) {
|
||||
throw new NexusError(
|
||||
'UNSUPPORTED',
|
||||
`Unknown STT provider "${name}". Available: ${[...this.stt.keys()].join(', ') || 'none'}`
|
||||
);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
listMediaProviders(): string[] {
|
||||
return [...this.media.keys()];
|
||||
}
|
||||
listSearchProviders(): string[] {
|
||||
return [...this.search.keys()];
|
||||
}
|
||||
listSttProviders(): string[] {
|
||||
return [...this.stt.keys()];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Provider abstraction. Adapters (NordRouter, RouterAI, GPTunnel, OpenRouter,
|
||||
* local STT) implement one or more of these capabilities. The MCP layer stays
|
||||
* provider-agnostic and selects an adapter by an explicit `provider` argument.
|
||||
*/
|
||||
|
||||
export type Capability = 'tts' | 'stt' | 'image' | 'video' | 'music' | 'search';
|
||||
|
||||
export interface MediaRequest {
|
||||
model: string;
|
||||
input: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface MediaResult {
|
||||
provider: string;
|
||||
capability: Capability;
|
||||
model: string;
|
||||
status: 'done';
|
||||
/** Local saved file path (when storage enabled). */
|
||||
filePath?: string;
|
||||
mimeType?: string;
|
||||
/** Remote URL from provider (may expire). */
|
||||
resultUrl?: string;
|
||||
/** Second track for music providers (Suno). */
|
||||
resultUrl2?: string;
|
||||
costUsd?: number;
|
||||
raw?: unknown;
|
||||
}
|
||||
|
||||
export interface SttInput {
|
||||
/** One of: local path, remote URL, or base64 data URL. */
|
||||
audioPath?: string;
|
||||
audioUrl?: string;
|
||||
audioBase64?: string;
|
||||
language?: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface SttResult {
|
||||
provider: string;
|
||||
text: string;
|
||||
language?: string;
|
||||
durationSec?: number;
|
||||
segments?: Array<{ start: number; end: number; text: string }>;
|
||||
}
|
||||
|
||||
export interface SearchInput {
|
||||
query: string;
|
||||
model?: string;
|
||||
recency?: 'day' | 'week' | 'month' | 'year';
|
||||
maxResults?: number;
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
provider: string;
|
||||
model: string;
|
||||
answer: string;
|
||||
citations: Array<{ index?: number; url: string; title?: string }>;
|
||||
costUsd?: number;
|
||||
raw?: unknown;
|
||||
}
|
||||
|
||||
export interface ModelInfo {
|
||||
id: string;
|
||||
type?: string;
|
||||
label?: string;
|
||||
estUsd?: number;
|
||||
fields?: unknown;
|
||||
}
|
||||
|
||||
export interface EstimateInput {
|
||||
model: string;
|
||||
input: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface UploadInput {
|
||||
/** Local path to read and upload. */
|
||||
path: string;
|
||||
name?: string;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
export interface UploadResult {
|
||||
/** Whatever the provider returns to reference the uploaded asset later. */
|
||||
ref: unknown;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface MediaProvider {
|
||||
readonly name: string;
|
||||
readonly capabilities: Capability[];
|
||||
generate(capability: Capability, req: MediaRequest): Promise<MediaResult>;
|
||||
estimate?(req: EstimateInput): Promise<{ usd: number }>;
|
||||
upload?(input: UploadInput): Promise<UploadResult>;
|
||||
listModels?(): Promise<ModelInfo[]>;
|
||||
}
|
||||
|
||||
export interface SearchProvider {
|
||||
readonly name: string;
|
||||
search(input: SearchInput): Promise<SearchResult>;
|
||||
}
|
||||
|
||||
export interface SttProvider {
|
||||
readonly name: string;
|
||||
transcribe(input: SttInput): Promise<SttResult>;
|
||||
}
|
||||
Reference in New Issue
Block a user