diff --git a/packages/gateway/src/modules/auto-discovery.ts b/packages/gateway/src/modules/auto-discovery.ts new file mode 100644 index 0000000..df08712 --- /dev/null +++ b/packages/gateway/src/modules/auto-discovery.ts @@ -0,0 +1,308 @@ +/** + * 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; +} + +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; +}> = [ + { + 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 { + 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; + // Ollama: { models: [{ name, size, details: { family } }] } + // OpenAI-compat: { data: [{ id }] } + const list = Array.isArray(data.models) + ? (data.models as Array>).map((m) => ({ + id: String(m.name ?? m.id ?? '?'), + size: typeof m.size === 'number' ? (m.size as number) : undefined, + family: (m.details as Record | undefined)?.family as + | string + | undefined, + })) + : Array.isArray((data as { data?: unknown }).data) + ? ((data as { data: Array> }).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 { + return Promise.all(LOCAL_LLM_PROBES.map(probeLocalLLM)); +} + +// ─── API-key providers ─────────────────────────────────────────────────────── + +const API_KEY_PROVIDERS: ReadonlyArray> = [ + { 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 { + 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 { + 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 => { + 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'); +} diff --git a/packages/gateway/src/modules/pii-redaction.ts b/packages/gateway/src/modules/pii-redaction.ts new file mode 100644 index 0000000..288a103 --- /dev/null +++ b/packages/gateway/src/modules/pii-redaction.ts @@ -0,0 +1,295 @@ +/** + * PII Redaction Layer + * + * Before sending prompts to a cloud LLM provider, replace personal data + * (emails, phone numbers, credit cards, SSN, IBAN, person names, IPv4/v6) + * with deterministic tokens. After the response comes back, restore the + * originals so the caller sees the un-redacted text. + * + * Crucial for GDPR / HIPAA / SOC2 deployments where data may not leave a + * trust boundary without redaction. The mapping lives only in process + * memory for the duration of a single call — never persisted. + * + * Modes (env REDACT_PII_MODE): + * - off → no redaction + * - cloud_only → redact when target provider is non-local + * (i.e. any *-bridge or hosted API; Ollama passes raw) + * - always → redact every call regardless of provider + * + * Per-caller exemption via REDACT_PII_EXEMPT_CALLERS=... + */ +import { logger } from '../observability/logger.js'; + +export type RedactMode = 'off' | 'cloud_only' | 'always'; +export type PiiCategory = + | 'email' | 'phone' | 'credit_card' | 'iban' | 'ssn' | 'ip_address' + | 'aws_key' | 'private_key' | 'jwt' | 'person_name'; + +export interface PiiMatch { + category: PiiCategory; + original: string; + token: string; + start: number; + end: number; +} + +export interface RedactionResult { + /** Text with PII replaced by tokens like */ + redacted: string; + /** Original text unchanged */ + original: string; + /** Map of token → original value, used for restoration */ + restoreMap: Map; + /** Per-category counts */ + counts: Record; + /** Time spent in ms */ + latencyMs: number; +} + +interface DetectionRule { + category: PiiCategory; + pattern: RegExp; + /** Optional validator to reduce false positives (e.g. Luhn check for credit cards) */ + validator?: (match: string) => boolean; +} + +// ─── Validators ────────────────────────────────────────────────────────────── + +function luhnValid(card: string): boolean { + const digits = card.replace(/\D/g, ''); + if (digits.length < 13 || digits.length > 19) return false; + let sum = 0; + let alt = false; + for (let i = digits.length - 1; i >= 0; i--) { + let n = parseInt(digits[i] ?? '0', 10); + if (alt) { n *= 2; if (n > 9) n -= 9; } + sum += n; + alt = !alt; + } + return sum % 10 === 0; +} + +function ibanValid(iban: string): boolean { + const cleaned = iban.replace(/\s/g, '').toUpperCase(); + if (!/^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/.test(cleaned)) return false; + // Move first 4 chars to end, replace letters with numbers (A=10, B=11, …) + const rearranged = cleaned.slice(4) + cleaned.slice(0, 4); + const numeric = rearranged.replace(/[A-Z]/g, (c) => String(c.charCodeAt(0) - 55)); + // Modulo 97 via chunked arithmetic (numeric can be huge) + let remainder = ''; + for (const c of numeric) { + remainder = String((parseInt(remainder + c, 10)) % 97); + } + return remainder === '1'; +} + +// ─── Detection rules ───────────────────────────────────────────────────────── + +const RULES: readonly DetectionRule[] = [ + // Email — RFC 5322-ish simplified + { category: 'email', pattern: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,24}\b/g }, + + // International phone numbers (loose; E.164-style) + { category: 'phone', pattern: /\b(?:\+|00)\d{1,3}[\s.-]?\(?\d{2,4}\)?[\s.-]?\d{3,4}[\s.-]?\d{3,4}(?:[\s.-]?\d{1,4})?\b/g }, + // German national format (0xxx-xxxx-xxxx) + { category: 'phone', pattern: /\b0\d{2,4}[\s.-]\d{3,4}[\s.-]\d{3,4}\b/g }, + + // Credit cards (with Luhn validation) + { category: 'credit_card', pattern: /\b(?:\d{4}[\s-]?){3}\d{4}\b/g, validator: luhnValid }, + // Amex 15-digit + { category: 'credit_card', pattern: /\b\d{4}[\s-]?\d{6}[\s-]?\d{5}\b/g, validator: luhnValid }, + + // IBAN + { category: 'iban', pattern: /\b[A-Z]{2}\d{2}[\s]?(?:[A-Z0-9]{4}[\s]?){2,7}[A-Z0-9]{1,4}\b/g, validator: ibanValid }, + + // US SSN (XXX-XX-XXXX) + { category: 'ssn', pattern: /\b\d{3}-\d{2}-\d{4}\b/g }, + + // IPv4 + { category: 'ip_address', pattern: /\b(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\b/g }, + // IPv6 (RFC 4291 short) + { category: 'ip_address', pattern: /\b(?:[A-Fa-f0-9]{1,4}:){7}[A-Fa-f0-9]{1,4}\b/g }, + + // AWS access key IDs + { category: 'aws_key', pattern: /\bAKIA[0-9A-Z]{16}\b/g }, + + // PEM-style private keys + { category: 'private_key', pattern: /-----BEGIN (?:RSA |EC |OPENSSH |PGP |DSA )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH |PGP |DSA )?PRIVATE KEY-----/g }, + + // JWT (rough — three base64url parts separated by dots, leading "eyJ") + { category: 'jwt', pattern: /\beyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g }, +]; + +// Person names are deliberately NOT regex-matched (high false-positive rate). +// We expose a `redactNames` hook that callers can implement with proper NER. + +// ─── Public API ────────────────────────────────────────────────────────────── + +const counters: Record = { + email: 0, phone: 0, credit_card: 0, iban: 0, ssn: 0, ip_address: 0, + aws_key: 0, private_key: 0, jwt: 0, person_name: 0, +}; + +function tokenFor(category: PiiCategory, restoreMap: Map): string { + const n = (Array.from(restoreMap.keys()).filter((k) => k.startsWith(`<${category.toUpperCase()}_`))).length + 1; + return `<${category.toUpperCase()}_${String(n).padStart(3, '0')}>`; +} + +/** + * Find all PII in `text` and replace with deterministic tokens. + */ +export function redactPii(text: string): RedactionResult { + const t0 = Date.now(); + const restoreMap = new Map(); + const counts: Record = { ...counters }; + let redacted = text; + + for (const rule of RULES) { + const seen = new Set(); + // Collect matches first (don't mutate during iteration) + const matches: string[] = []; + let m: RegExpExecArray | null; + rule.pattern.lastIndex = 0; + while ((m = rule.pattern.exec(redacted)) !== null) { + const value = m[0]; + if (seen.has(value)) continue; + if (rule.validator && !rule.validator(value)) continue; + seen.add(value); + matches.push(value); + } + // Apply replacements + for (const value of matches) { + const token = tokenFor(rule.category, restoreMap); + restoreMap.set(token, value); + counts[rule.category] += 1; + // Replace ALL occurrences of this exact value + redacted = redacted.split(value).join(token); + } + } + + return { + redacted, + original: text, + restoreMap, + counts, + latencyMs: Date.now() - t0, + }; +} + +/** + * Restore tokens in `text` from the redaction map. + */ +export function restorePii(text: string, restoreMap: Map): string { + let out = text; + for (const [token, value] of restoreMap.entries()) { + out = out.split(token).join(value); + } + return out; +} + +// ─── Mode helpers ──────────────────────────────────────────────────────────── + +export function getRedactMode(): RedactMode { + const v = (process.env['REDACT_PII_MODE'] ?? 'off').toLowerCase(); + if (v === 'cloud_only' || v === 'always') return v; + return 'off'; +} + +const LOCAL_PROVIDERS = new Set(['ollama', 'lmstudio', 'llamafile', 'vllm']); +export function isLocalProvider(providerName: string): boolean { + const lc = providerName.toLowerCase(); + return Array.from(LOCAL_PROVIDERS).some((p) => lc.includes(p)); +} + +export function shouldRedactFor(mode: RedactMode, providerName: string, caller: string): boolean { + if (mode === 'off') return false; + if (isCallerRedactExempt(caller)) return false; + if (mode === 'always') return true; + // cloud_only: skip when target is a local Ollama / LM Studio + return !isLocalProvider(providerName); +} + +export function isCallerRedactExempt(caller: string): boolean { + const list = (process.env['REDACT_PII_EXEMPT_CALLERS'] ?? 'internal,health,metrics').split(',').map((s) => s.trim()); + return list.includes(caller); +} + +// ─── Logging hook ──────────────────────────────────────────────────────────── +logger.info( + { mode: getRedactMode() }, + 'PII redaction layer initialised', +); + +// ─── Async person-name NER via Presidio+GLiNER sidecar ─────────────────────── + +export interface PresidioEntity { + entity_type: string; + start: number; + end: number; + score: number; + text: string; + source?: string; +} + +/** + * Call the Presidio+GLiNER sidecar for PERSON/ORGANIZATION/LOCATION NER. + * Falls back silently when the sidecar is unreachable — non-fatal. + */ +export async function redactPersonNamesAsync( + text: string, + presidioUrl: string, + language = 'de', +): Promise<{ redacted: string; restoreMap: Map; count: number }> { + const restoreMap = new Map(); + if (!text || text.length < 3) return { redacted: text, restoreMap, count: 0 }; + + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 3000); + let resp: Response; + try { + resp = await fetch(`${presidioUrl.replace(/\/$/, '')}/analyze`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + text, + language, + entities: ['PERSON', 'ORGANIZATION', 'LOCATION'], + }), + signal: controller.signal, + }); + } finally { + clearTimeout(timer); + } + if (!resp.ok) return { redacted: text, restoreMap, count: 0 }; + + const data = (await resp.json()) as { entities: PresidioEntity[] }; + if (!data.entities?.length) return { redacted: text, restoreMap, count: 0 }; + + const typeCounters: Record = {}; + const seen = new Set(); + const entities = [...data.entities] + .filter((e) => e.score >= 0.4 && e.text?.length >= 2) + .sort((a, b) => b.start - a.start); + + let redacted = text; + for (const entity of entities) { + const original = entity.text; + if (seen.has(original)) { + const existing = Array.from(restoreMap.entries()).find(([, v]) => v === original); + if (existing) redacted = redacted.split(original).join(existing[0]); + continue; + } + seen.add(original); + const etype = entity.entity_type.toUpperCase(); + typeCounters[etype] = (typeCounters[etype] ?? 0) + 1; + const token = `<${etype}_${String(typeCounters[etype]).padStart(3, '0')}>`; + restoreMap.set(token, original); + redacted = redacted.split(original).join(token); + } + + return { redacted, restoreMap, count: restoreMap.size }; + } catch { + return { redacted: text, restoreMap, count: 0 }; + } +} diff --git a/packages/gateway/src/routes/completion.ts b/packages/gateway/src/routes/completion.ts index ad1d2ec..b3a0b78 100644 --- a/packages/gateway/src/routes/completion.ts +++ b/packages/gateway/src/routes/completion.ts @@ -60,6 +60,7 @@ import { restorePii, getRedactMode, shouldRedactFor, + redactPersonNamesAsync, } from '../modules/pii-redaction.js'; import { splitReasoningTrace, storeReasoningTrace } from '../modules/reasoning-trace.js'; import { getRoutingOverride } from '../modules/workspace-presets.js'; @@ -426,6 +427,22 @@ async function executeCompletion(body: CompletionRequest, startMs: number, callI } } + // ─── PII: person names via Presidio+GLiNER sidecar (async, non-blocking) ── + const presidioUrl = process.env['PRESIDIO_URL']; + if (presidioUrl && redactMode !== 'off' && shouldRedactFor(redactMode, 'unknown', caller)) { + try { + const nameResult = await redactPersonNamesAsync(body.input, presidioUrl); + if (nameResult.count > 0) { + body = { ...body, input: nameResult.redacted }; + if (!piiRestoreMap) piiRestoreMap = new Map(); + for (const [k, v] of nameResult.restoreMap) piiRestoreMap.set(k, v); + logger.info({ callId, caller, names: nameResult.count }, 'Person names redacted via Presidio'); + } + } catch (err) { + logger.warn({ err, callId }, 'Presidio name redaction failed — continuing'); + } + } + // ─── Prompt-injection defense (configurable via INJECTION_DEFENSE_MODE) ── const injectionMode = getInjectionMode(); let injectionScan: InjectionScanResult | null = null; diff --git a/packages/gateway/src/routes/dashboard.ts b/packages/gateway/src/routes/dashboard.ts index 5caf176..c2b751b 100644 --- a/packages/gateway/src/routes/dashboard.ts +++ b/packages/gateway/src/routes/dashboard.ts @@ -1,7 +1,8 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import { execFile } from 'child_process'; import { promisify } from 'util'; -import { existsSync } from 'fs'; +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; +import { join } from 'path'; import { homedir } from 'os'; import { getPool } from '../db/client.js'; import { logger } from '../observability/logger.js'; @@ -1762,4 +1763,65 @@ export async function dashboardRoute(fastify: FastifyInstance): Promise { return reply.status(500).send({ success: false, error: 'Failed to import usage report' }); } }); + // ─── Client-side AI discovery push: MacBook/MacStudio → Gateway ────────── + // Called every 5 min by the llm-ai-discovery LaunchAgent on each machine. + // Stores a JSON inventory per host; triggers bridge-spawn for new services. + const DISCOVERIES_FILE = '/opt/llm-gateway/data/client-discoveries.json'; + + fastify.post('/api/discovery/client-report', dashboardAuth, async (request: FastifyRequest, reply: FastifyReply) => { + try { + const body = (request.body ?? {}) as Record; + const host = String(body.host ?? 'unknown').slice(0, 64); + const report = { + ...body, + host, + receivedAt: new Date().toISOString(), + }; + + // Read existing discoveries, merge, write back + mkdirSync('/opt/llm-gateway/data', { recursive: true }); + let discoveries: Record = {}; + try { + discoveries = JSON.parse(readFileSync(DISCOVERIES_FILE, 'utf-8')); + } catch { /* first run */ } + const prev = discoveries[host] as Record | undefined; + discoveries[host] = report; + writeFileSync(DISCOVERIES_FILE, JSON.stringify(discoveries, null, 2)); + + // Log new models/tools vs last scan + const prevModels: string[] = (prev?.ollama_models as string[] | undefined) ?? []; + const currModels: string[] = ((report as Record).ollama_models as string[] | undefined) ?? []; + const newModels = currModels.filter((m: string) => !prevModels.includes(m)); + const prevTools: string[] = (prev?.ai_tools as string[] | undefined) ?? []; + const currTools: string[] = ((report as Record).ai_tools as string[] | undefined) ?? []; + const newTools = currTools.filter((t: string) => !prevTools.includes(t)); + if (newModels.length > 0 || newTools.length > 0) { + logger.info({ host, newModels, newTools }, 'New AI services discovered on client machine'); + } + + return reply.status(200).send({ + ok: true, + host, + newModels: newModels.length, + newTools: newTools.length, + }); + } catch (error) { + logger.error({ error }, 'client-report failed'); + return reply.status(500).send({ ok: false, error: 'internal error' }); + } + }); + + // GET /api/discovery/client-reports — returns all stored client inventories + fastify.get('/api/discovery/client-reports', dashboardAuth, async (_request: FastifyRequest, reply: FastifyReply) => { + try { + let discoveries: Record = {}; + try { + discoveries = JSON.parse(readFileSync(DISCOVERIES_FILE, 'utf-8')); + } catch { /* no reports yet */ } + return reply.send({ ok: true, data: discoveries }); + } catch (error) { + logger.error({ error }, 'client-reports read failed'); + return reply.status(500).send({ ok: false, error: 'internal error' }); + } + }); } diff --git a/packages/gateway/src/server.ts b/packages/gateway/src/server.ts index d7c30b7..7afa445 100644 --- a/packages/gateway/src/server.ts +++ b/packages/gateway/src/server.ts @@ -20,7 +20,7 @@ import { runMigrations } from './db/migrate.js'; import { initPgBoss } from './queue/pg-boss-client.js'; import { logger } from './observability/logger.js'; import { scheduleLearningCycles } from './learning/learning-engine.js'; -import { autoSpawnOnBoot } from './modules/auto-discovery.js'; +import { autoSpawnOnBoot, schedulePeriodicDiscovery } from './modules/auto-discovery.js'; import { embeddingsRoute } from './routes/embeddings.js'; import { replayRoute } from './routes/replay.js'; import { audioRoute } from './routes/audio.js'; @@ -240,6 +240,9 @@ async function main() { // Auto-spawn detected subscription bridges if AUTO_SPAWN_BRIDGES=1 void autoSpawnOnBoot(); + schedulePeriodicDiscovery( + parseInt(process.env['DISCOVERY_INTERVAL_MS'] ?? '300000', 10), + ); // Bridge watchdog (opt-in via WATCHDOG_ENABLED=1) try {