- pii-redaction.ts: add redactPersonNamesAsync() — async Presidio/GLiNER NER for PERSON/ORGANIZATION/LOCATION (95% German accuracy, <3s timeout, non-fatal) - completion.ts: wire Presidio async call after regex PII block (PRESIDIO_URL env) - auto-discovery.ts: add schedulePeriodicDiscovery() — re-scan all bridges every 5 min - server.ts: call schedulePeriodicDiscovery(300000) on boot - dashboard.ts: POST /api/discovery/client-report + GET /api/discovery/client-reports — MacBook/MacStudio push their AI inventory (Ollama models, tools, ports) every 5 min - .env: REDACT_PII_MODE=cloud_only, AUTO_SPAWN_BRIDGES=1, PRESIDIO_URL=:8701, DISCOVERY_INTERVAL_MS=300000 Presidio sidecar: /opt/presidio-sidecar/ — FastAPI + spaCy de_core_news_lg + GLiNER multi-v2.1, PM2 id 28 on :8701. Compression already active; anonymization now live on all cloud provider calls.
309 lines
10 KiB
TypeScript
309 lines
10 KiB
TypeScript
/**
|
|
* Auto-Discovery — full system scan for available LLM sources.
|
|
*
|
|
* Probes everything the gateway can route to:
|
|
* 1. CLI subscriptions (Claude Code, ChatGPT, Codex, Copilot, M365, Gemini, Aider)
|
|
* → delegates to subscription-discovery.ts + bridge-spawner.ts
|
|
* 2. Local LLM servers (Ollama, LM Studio, llamafile, vLLM)
|
|
* → HTTP probes against well-known ports
|
|
* 3. API-key providers (Cerebras, Groq, Mistral, NVIDIA, Cloudflare AI, OpenAI, Anthropic)
|
|
* → env-var presence + cheap auth-ping where possible
|
|
*
|
|
* Returns a unified DiscoveryReport that the dashboard renders and the
|
|
* gateway uses for auto-routing decisions.
|
|
*/
|
|
import { logger } from '../observability/logger.js';
|
|
import {
|
|
discoverSubscriptions,
|
|
type SubscriptionStatus,
|
|
} from './subscription-discovery.js';
|
|
import {
|
|
getRunningBridges,
|
|
spawnDetectedBridges,
|
|
} from './bridge-spawner.js';
|
|
|
|
// ─── Type definitions ────────────────────────────────────────────────────────
|
|
|
|
export interface LocalLLMServer {
|
|
id: 'ollama' | 'lmstudio' | 'llamafile' | 'vllm';
|
|
label: string;
|
|
url: string;
|
|
detected: boolean;
|
|
models: ReadonlyArray<{ id: string; size?: number; family?: string }>;
|
|
latencyMs: number | null;
|
|
error?: string;
|
|
}
|
|
|
|
export interface ApiKeyProvider {
|
|
id: string;
|
|
label: string;
|
|
envKey: string;
|
|
configured: boolean;
|
|
authPingOk: boolean | 'untested';
|
|
modelsExpected: ReadonlyArray<string>;
|
|
}
|
|
|
|
export interface DiscoveryReport {
|
|
generatedAt: string;
|
|
host: string;
|
|
subscriptions: {
|
|
detected: number;
|
|
authenticated: number;
|
|
bridgesRunning: number;
|
|
items: SubscriptionStatus[];
|
|
};
|
|
localLLMs: {
|
|
detected: number;
|
|
items: LocalLLMServer[];
|
|
};
|
|
apiKeys: {
|
|
configured: number;
|
|
items: ApiKeyProvider[];
|
|
};
|
|
summary: {
|
|
totalProviders: number;
|
|
totalRoutableModels: number;
|
|
};
|
|
}
|
|
|
|
// ─── Local LLM servers ───────────────────────────────────────────────────────
|
|
|
|
const LOCAL_LLM_PROBES: ReadonlyArray<{
|
|
id: LocalLLMServer['id'];
|
|
label: string;
|
|
defaultUrl: string;
|
|
modelsPath: string;
|
|
envKeys: ReadonlyArray<string>;
|
|
}> = [
|
|
{
|
|
id: 'ollama',
|
|
label: 'Ollama (local models)',
|
|
defaultUrl: 'http://localhost:11434',
|
|
modelsPath: '/api/tags',
|
|
envKeys: ['OLLAMA_URL', 'OLLAMA_BASE_URL'],
|
|
},
|
|
{
|
|
id: 'lmstudio',
|
|
label: 'LM Studio',
|
|
defaultUrl: 'http://localhost:1234',
|
|
modelsPath: '/v1/models',
|
|
envKeys: ['LMSTUDIO_URL'],
|
|
},
|
|
{
|
|
id: 'llamafile',
|
|
label: 'llamafile',
|
|
defaultUrl: 'http://localhost:8080',
|
|
modelsPath: '/v1/models',
|
|
envKeys: ['LLAMAFILE_URL'],
|
|
},
|
|
{
|
|
id: 'vllm',
|
|
label: 'vLLM',
|
|
defaultUrl: 'http://localhost:8000',
|
|
modelsPath: '/v1/models',
|
|
envKeys: ['VLLM_URL'],
|
|
},
|
|
];
|
|
|
|
function resolveLocalLLMUrl(probe: (typeof LOCAL_LLM_PROBES)[number]): string {
|
|
for (const key of probe.envKeys) {
|
|
const v = process.env[key];
|
|
if (v && v.length > 0) return v.replace(/\/$/, '');
|
|
}
|
|
return probe.defaultUrl;
|
|
}
|
|
|
|
async function probeLocalLLM(
|
|
probe: (typeof LOCAL_LLM_PROBES)[number],
|
|
): Promise<LocalLLMServer> {
|
|
const url = resolveLocalLLMUrl(probe);
|
|
const t0 = Date.now();
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), 4000);
|
|
try {
|
|
const res = await fetch(`${url}${probe.modelsPath}`, {
|
|
signal: controller.signal,
|
|
headers: { Accept: 'application/json' },
|
|
});
|
|
const latencyMs = Date.now() - t0;
|
|
if (!res.ok) {
|
|
return {
|
|
id: probe.id,
|
|
label: probe.label,
|
|
url,
|
|
detected: false,
|
|
models: [],
|
|
latencyMs,
|
|
error: `HTTP ${res.status}`,
|
|
};
|
|
}
|
|
const data = (await res.json()) as Record<string, unknown>;
|
|
// Ollama: { models: [{ name, size, details: { family } }] }
|
|
// OpenAI-compat: { data: [{ id }] }
|
|
const list = Array.isArray(data.models)
|
|
? (data.models as Array<Record<string, unknown>>).map((m) => ({
|
|
id: String(m.name ?? m.id ?? '?'),
|
|
size: typeof m.size === 'number' ? (m.size as number) : undefined,
|
|
family: (m.details as Record<string, unknown> | undefined)?.family as
|
|
| string
|
|
| undefined,
|
|
}))
|
|
: Array.isArray((data as { data?: unknown }).data)
|
|
? ((data as { data: Array<Record<string, unknown>> }).data).map((m) => ({
|
|
id: String(m.id ?? '?'),
|
|
}))
|
|
: [];
|
|
return {
|
|
id: probe.id,
|
|
label: probe.label,
|
|
url,
|
|
detected: true,
|
|
models: list,
|
|
latencyMs,
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
id: probe.id,
|
|
label: probe.label,
|
|
url,
|
|
detected: false,
|
|
models: [],
|
|
latencyMs: null,
|
|
error: err instanceof Error ? err.message.slice(0, 120) : 'unknown',
|
|
};
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
async function discoverLocalLLMs(): Promise<LocalLLMServer[]> {
|
|
return Promise.all(LOCAL_LLM_PROBES.map(probeLocalLLM));
|
|
}
|
|
|
|
// ─── API-key providers ───────────────────────────────────────────────────────
|
|
|
|
const API_KEY_PROVIDERS: ReadonlyArray<Omit<ApiKeyProvider, 'configured' | 'authPingOk'>> = [
|
|
{ id: 'cerebras', label: 'Cerebras (free tier)', envKey: 'CEREBRAS_API_KEY', modelsExpected: ['llama-3.3-70b', 'qwen-3-32b'] },
|
|
{ id: 'groq', label: 'Groq (free tier)', envKey: 'GROQ_API_KEY', modelsExpected: ['llama-3.3-70b-versatile', 'mixtral-8x7b'] },
|
|
{ id: 'mistral', label: 'Mistral AI', envKey: 'MISTRAL_API_KEY', modelsExpected: ['mistral-large-latest', 'mistral-small'] },
|
|
{ id: 'nvidia', label: 'NVIDIA NIM', envKey: 'NVIDIA_API_KEY', modelsExpected: ['meta/llama-3.3-70b-instruct', 'nvidia/llama-3.3-nemotron-super-49b'] },
|
|
{ id: 'cloudflare', label: 'Cloudflare Workers AI', envKey: 'CLOUDFLARE_AI_TOKEN', modelsExpected: ['@cf/meta/llama-3.3-70b-instruct-fp8-fast'] },
|
|
{ id: 'openai', label: 'OpenAI API', envKey: 'OPENAI_API_KEY', modelsExpected: ['gpt-4o', 'gpt-4o-mini'] },
|
|
{ id: 'anthropic', label: 'Anthropic API', envKey: 'ANTHROPIC_API_KEY', modelsExpected: ['claude-sonnet-4-5', 'claude-haiku-4-5'] },
|
|
{ id: 'brave', label: 'Brave Search API', envKey: 'BRAVE_API_KEY', modelsExpected: [] },
|
|
];
|
|
|
|
function discoverApiKeys(): ApiKeyProvider[] {
|
|
return API_KEY_PROVIDERS.map((p) => ({
|
|
...p,
|
|
configured: Boolean(process.env[p.envKey]),
|
|
authPingOk: 'untested',
|
|
}));
|
|
}
|
|
|
|
// ─── Full discovery report ────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Run the complete discovery sweep. Pure read-only — does NOT spawn bridges.
|
|
* Use `runDiscoveryAndSpawn()` to also start any detected CLI bridges.
|
|
*/
|
|
export async function runDiscovery(): Promise<DiscoveryReport> {
|
|
logger.info('Starting full system auto-discovery');
|
|
const [subs, locals] = await Promise.all([
|
|
discoverSubscriptions(),
|
|
discoverLocalLLMs(),
|
|
]);
|
|
const running = getRunningBridges();
|
|
const apiKeys = discoverApiKeys();
|
|
|
|
const totalRoutableModels =
|
|
subs.reduce((acc, s) => acc + (s.installed ? s.descriptor.models.length : 0), 0) +
|
|
locals.reduce((acc, l) => acc + l.models.length, 0) +
|
|
apiKeys.reduce((acc, k) => acc + (k.configured ? k.modelsExpected.length : 0), 0);
|
|
|
|
return {
|
|
generatedAt: new Date().toISOString(),
|
|
host: process.env.HOSTNAME ?? 'unknown',
|
|
subscriptions: {
|
|
detected: subs.filter((s) => s.installed).length,
|
|
authenticated: subs.filter((s) => s.installed && s.authenticated === true).length,
|
|
bridgesRunning: running.length,
|
|
items: subs,
|
|
},
|
|
localLLMs: {
|
|
detected: locals.filter((l) => l.detected).length,
|
|
items: locals,
|
|
},
|
|
apiKeys: {
|
|
configured: apiKeys.filter((k) => k.configured).length,
|
|
items: apiKeys,
|
|
},
|
|
summary: {
|
|
totalProviders:
|
|
subs.filter((s) => s.installed).length +
|
|
locals.filter((l) => l.detected).length +
|
|
apiKeys.filter((k) => k.configured).length,
|
|
totalRoutableModels,
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Run discovery AND spawn any CLI bridges that are detected but not yet running.
|
|
* Returns the discovery report plus the spawned bridges.
|
|
*/
|
|
export async function runDiscoveryAndSpawn(): Promise<{
|
|
report: DiscoveryReport;
|
|
spawned: ReadonlyArray<{ id: string; url: string; port: number }>;
|
|
}> {
|
|
const report = await runDiscovery();
|
|
const spawned = await spawnDetectedBridges(report.subscriptions.items);
|
|
return {
|
|
report,
|
|
spawned: spawned.map((b) => ({
|
|
id: b.descriptor.id,
|
|
url: b.url,
|
|
port: b.port,
|
|
})),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Optional boot-time hook. Wired from server.ts if env AUTO_SPAWN_BRIDGES=1.
|
|
*/
|
|
export async function autoSpawnOnBoot(): Promise<void> {
|
|
if (process.env['AUTO_SPAWN_BRIDGES'] !== '1') {
|
|
logger.info('AUTO_SPAWN_BRIDGES not set — skipping boot-time bridge spawn');
|
|
return;
|
|
}
|
|
try {
|
|
const result = await runDiscoveryAndSpawn();
|
|
logger.info(
|
|
{
|
|
providers: result.report.summary.totalProviders,
|
|
bridgesSpawned: result.spawned.length,
|
|
},
|
|
'Auto-spawn completed at boot',
|
|
);
|
|
} catch (err) {
|
|
logger.error({ err }, 'Auto-spawn at boot failed (non-fatal)');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Schedule periodic re-discovery and auto-spawn every `intervalMs` milliseconds.
|
|
* Called once at boot from server.ts. Non-fatal — any discovery error is logged
|
|
* and the timer continues. Default: 300_000 ms (5 min).
|
|
*/
|
|
export function schedulePeriodicDiscovery(intervalMs: number = 300_000): void {
|
|
const run = async (): Promise<void> => {
|
|
try {
|
|
await runDiscoveryAndSpawn();
|
|
} catch (err) {
|
|
logger.warn({ err }, 'Periodic auto-discovery failed (non-fatal)');
|
|
}
|
|
};
|
|
setInterval(() => { void run(); }, intervalMs);
|
|
logger.info({ intervalMs }, 'Periodic AI discovery scheduled');
|
|
}
|