import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { requireDashboardAuth } from '../modules/admin-auth.js'; import { runDiscovery } from '../modules/auto-discovery.js'; import { discoverSubscriptions, type SubscriptionId, type SubscriptionStatus, } from '../modules/subscription-discovery.js'; import { getRunningBridges, spawnBridge, spawnDetectedBridges, } from '../modules/bridge-spawner.js'; import { getPublicSettings, saveSettings } from '../modules/settings-store.js'; import { getAllProviders, getAvailableProviders } from '../pipeline/external-providers.js'; import { logger } from '../observability/logger.js'; interface SubscriptionBridgeBody { subscription_id?: SubscriptionId; } interface SubscriptionJoinBody extends SubscriptionBridgeBody { bridge_url?: string; auto_spawn?: boolean; } interface SubscriptionTestBody extends SubscriptionBridgeBody { model?: string; message?: string; timeout_ms?: number; } interface SubscriptionChatParams { subscription_id: SubscriptionId; } type SubscriptionChatBody = { model?: string; messages?: readonly unknown[]; temperature?: number; max_tokens?: number; stream?: boolean; [key: string]: unknown; }; type RunningBridge = ReturnType[number]; function configuredBridgeUrl(status: SubscriptionStatus, runningById: Map): string | null { return runningById.get(status.descriptor.id)?.url ?? status.bridgeUrl ?? process.env[status.descriptor.bridgeEnvKey] ?? null; } function defaultBridgeUrl(status: SubscriptionStatus): string { return `http://localhost:${status.descriptor.bridgePort}`; } function bridgeBaseUrl(url: string): string { return url.replace(/\/+$/, '').replace(/\/v1$/, ''); } function bridgeEndpoint(url: string, path: string): string { return `${bridgeBaseUrl(url)}${path}`; } function getRequestBaseUrl(request: FastifyRequest): string { const forwardedProto = request.headers['x-forwarded-proto']; const proto = Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto; const protocol = proto || (request.protocol ?? 'http'); const host = request.headers['x-forwarded-host'] || request.headers.host || request.hostname; const selectedHost = Array.isArray(host) ? host[0] : host; return `${protocol}://${selectedHost}`.replace(/\/+$/, ''); } function buildAccessCard( status: SubscriptionStatus, baseUrl: string, bridgeUrl: string | null, ) { const defaultModel = status.descriptor.models[0]?.id ?? status.descriptor.id; const modelList = status.descriptor.models.map((model) => model.id); const subscriptionApiUrl = `${baseUrl}/api/subscriptions/${encodeURIComponent(status.descriptor.id)}/v1/chat/completions`; return { kind: 'gateway-subscription-access', subscriptionId: status.descriptor.id, providerName: status.descriptor.providerName, gatewayBaseUrl: baseUrl, chatCompletionsUrl: `${baseUrl}/v1/chat/completions`, subscriptionChatCompletionsUrl: subscriptionApiUrl, responsesUrl: `${baseUrl}/v1/responses`, modelsUrl: `${baseUrl}/v1/models`, subscriptionStatusUrl: `${baseUrl}/api/subscriptions?search=${encodeURIComponent(status.descriptor.id)}`, headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ', 'X-Caller-ID': 'your-service-name', 'X-LLM-Gateway-Subscription': status.descriptor.id, }, env: { LLM_GATEWAY_URL: baseUrl, LLM_GATEWAY_API_KEY: '', LLM_GATEWAY_MODEL: defaultModel, LLM_GATEWAY_SUBSCRIPTION: status.descriptor.id, }, models: modelList, defaultModel, bridge: { url: bridgeUrl ?? defaultBridgeUrl(status), envKey: status.descriptor.bridgeEnvKey, port: status.descriptor.bridgePort, internalOnly: true, }, curl: [ `curl -X POST ${baseUrl}/v1/chat/completions`, ` -H "Content-Type: application/json"`, ` -H "Authorization: Bearer $LLM_GATEWAY_API_KEY"`, ` -H "X-Caller-ID: pilot-client"`, ` -H "X-LLM-Gateway-Subscription: ${status.descriptor.id}"`, ` -d '{"model":"${defaultModel}","messages":[{"role":"user","content":"gateway bridge smoke"}]}'`, ].join(' \\\n'), subscriptionCurl: [ `curl -X POST ${subscriptionApiUrl}`, ` -H "Content-Type: application/json"`, ` -H "Authorization: Bearer $LLM_GATEWAY_API_KEY"`, ` -H "X-Caller-ID: pilot-client"`, ` -d '{"model":"${defaultModel}","messages":[{"role":"user","content":"gateway bridge smoke"}]}'`, ].join(' \\\n'), note: 'Use a gateway API key/admin token from your deployment. OAuth/subscription secrets stay inside the bridge process and are never exposed here.', }; } function matchesSearch(subscription: ReturnType, search: string): boolean { if (!search) return true; const haystack = [ subscription.id, subscription.label, subscription.providerName, subscription.command, subscription.status, ...subscription.models.map((model) => model.id), ].join(' ').toLowerCase(); return haystack.includes(search.toLowerCase()); } function summarizeSubscription(status: SubscriptionStatus, runningById: Map, baseUrl: string) { const settings = getPublicSettings(); const runtime = runningById.get(status.descriptor.id); const userDeclared = settings.subscriptions[status.descriptor.id]?.enabled === true; const detected = status.installed; const available = detected || userDeclared || status.bridgeRunning; const bridgeUrl = configuredBridgeUrl(status, runningById); const bridgeRunning = !!runtime || status.bridgeRunning; const canSpawn = detected && status.authenticated !== false && !bridgeRunning; const routeStatus = bridgeRunning ? 'ready' : !available ? 'not_detected' : status.authenticated === false ? 'auth_required' : canSpawn ? 'spawnable' : bridgeUrl ? 'bridge_unreachable' : 'config_needed'; return { id: status.descriptor.id, label: status.descriptor.label, providerName: status.descriptor.providerName, command: status.descriptor.command, detected, userDeclared, installed: available, authenticated: detected ? status.authenticated : (userDeclared ? 'unknown' : false), status: routeStatus, version: status.version ?? null, bridgeRunning, bridgeUrl, defaultBridgeUrl: defaultBridgeUrl(status), bridgePort: status.descriptor.bridgePort, bridgeEnvKey: status.descriptor.bridgeEnvKey, autoSpawned: !!runtime, canSpawn, joined: userDeclared, startedAt: runtime?.startedAt.toISOString() ?? null, models: status.descriptor.models, access: buildAccessCard(status, baseUrl, bridgeUrl), }; } async function getSubscriptionSnapshot(request: FastifyRequest, search = '') { const statuses = await discoverSubscriptions(); const runningBridges = getRunningBridges(); const runningById = new Map(runningBridges.map((bridge) => [bridge.descriptor.id, bridge])); const baseUrl = getRequestBaseUrl(request); const subscriptions = statuses .map((status) => summarizeSubscription(status, runningById, baseUrl)) .filter((subscription) => matchesSearch(subscription, search)); const providers = getAllProviders(); const availableProviders = getAvailableProviders(); return { subscriptions, summary: { total: subscriptions.length, installed: subscriptions.filter((sub) => sub.installed).length, detected: subscriptions.filter((sub) => sub.detected).length, userDeclared: subscriptions.filter((sub) => sub.userDeclared).length, running: subscriptions.filter((sub) => sub.bridgeRunning).length, spawnable: subscriptions.filter((sub) => sub.canSpawn).length, routableProviders: availableProviders.length, unifiedEndpoint: '/v1/chat/completions', scanEndpoint: '/api/subscriptions/scan', spawnEndpoint: '/api/subscriptions/bridges', joinEndpoint: '/api/subscriptions/join', testEndpoint: '/api/subscriptions/test', search, }, providers: { available: availableProviders.map((provider) => provider.name), all: providers.map((provider) => ({ name: provider.name, envKey: provider.envKey, enabled: provider.enabled, configured: availableProviders.some((available) => available.name === provider.name), models: provider.models.map((model) => model.id), })), }, }; } async function getSubscriptionStatusOrReply( subscriptionId: SubscriptionId, reply: FastifyReply ): Promise { const statuses = await discoverSubscriptions(); const status = statuses.find((candidate) => candidate.descriptor.id === subscriptionId); if (!status) { reply.status(404).send({ success: false, error: 'subscription not found' }); return null; } return status; } async function ensureSubscriptionBridge(status: SubscriptionStatus): Promise { const running = getRunningBridges().find((bridge) => bridge.descriptor.id === status.descriptor.id); if (running) return running; if (status.bridgeRunning || status.bridgeUrl || process.env[status.descriptor.bridgeEnvKey]) return null; if (!status.installed || status.authenticated === false) return null; return await spawnBridge(status.descriptor); } function contentFromBridgeResponse(data: any): string { return data?.choices?.[0]?.message?.content ?? data?.content ?? data?.response ?? data?.message?.content ?? ''; } async function postBridgeChat( url: string, model: string, message: string, timeoutMs: number ): Promise<{ statusCode: number; ok: boolean; data: any; latencyMs: number }> { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); const startedAt = Date.now(); const parseResponse = async (response: Response): Promise => { const text = await response.text(); try { return JSON.parse(text); } catch { return { raw: text }; } }; try { const response = await fetch(bridgeEndpoint(url, '/v1/chat/completions'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model, messages: [{ role: 'user', content: message }], temperature: 0, max_tokens: 128, }), signal: controller.signal, }); const data = await parseResponse(response); if (response.status !== 404) { return { statusCode: response.status, ok: response.ok, data, latencyMs: Date.now() - startedAt }; } const generateResponse = await fetch(bridgeEndpoint(url, '/api/generate'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model, prompt: message, stream: false, temperature: 0, max_tokens: 128, }), signal: controller.signal, }); const generateData = await parseResponse(generateResponse); return { statusCode: generateResponse.status, ok: generateResponse.ok, data: generateData, latencyMs: Date.now() - startedAt, }; } catch (error) { return { statusCode: 0, ok: false, data: { error: error instanceof Error ? error.message : 'bridge request failed', }, latencyMs: Date.now() - startedAt, }; } finally { clearTimeout(timer); } } export async function subscriptionsRoute(fastify: FastifyInstance): Promise { const auth = { preHandler: requireDashboardAuth }; fastify.get('/api/subscriptions', auth, async (request: FastifyRequest, reply: FastifyReply) => { const query = request.query as { search?: string; q?: string }; const snapshot = await getSubscriptionSnapshot(request, query.search ?? query.q ?? ''); return reply.send({ success: true, data: snapshot, meta: { timestamp: new Date().toISOString() } }); }); fastify.post('/api/subscriptions/search', auth, async (request: FastifyRequest, reply: FastifyReply) => { const body = (request.body ?? {}) as { search?: string; q?: string }; const snapshot = await getSubscriptionSnapshot(request, body.search ?? body.q ?? ''); return reply.send({ success: true, data: snapshot, meta: { timestamp: new Date().toISOString() } }); }); fastify.post('/api/subscriptions/scan', auth, async (request: FastifyRequest, reply: FastifyReply) => { try { const body = (request.body ?? {}) as { search?: string; q?: string }; const search = body.search ?? body.q ?? ''; const discovery = await runDiscovery(); const snapshot = await getSubscriptionSnapshot(request, search); return reply.send({ success: true, data: { ...snapshot, discovery, }, meta: { timestamp: new Date().toISOString() }, }); } catch (error) { logger.error({ error }, 'Subscription scan failed'); return reply.status(500).send({ success: false, error: 'subscription scan failed' }); } }); fastify.post('/api/subscriptions/bridges', auth, async (request: FastifyRequest, reply: FastifyReply) => { try { const body = (request.body ?? {}) as SubscriptionBridgeBody; const statuses = await discoverSubscriptions(); const targets = body.subscription_id ? statuses.filter((status) => status.descriptor.id === body.subscription_id) : statuses; if (body.subscription_id && targets.length === 0) { return reply.status(404).send({ success: false, error: 'subscription not found' }); } const spawned = await spawnDetectedBridges(targets); const snapshot = await getSubscriptionSnapshot(request); return reply.send({ success: true, data: { spawnedCount: spawned.length, spawned: spawned.map((bridge) => ({ id: bridge.descriptor.id, providerName: bridge.descriptor.providerName, url: bridge.url, port: bridge.port, startedAt: bridge.startedAt.toISOString(), })), ...snapshot, }, meta: { timestamp: new Date().toISOString() }, }); } catch (error) { logger.error({ error }, 'Subscription bridge spawn failed'); return reply.status(500).send({ success: false, error: 'subscription bridge spawn failed' }); } }); fastify.post('/api/subscriptions/join', auth, async (request: FastifyRequest, reply: FastifyReply) => { try { const body = (request.body ?? {}) as SubscriptionJoinBody; if (!body.subscription_id) { return reply.status(400).send({ success: false, error: 'subscription_id required' }); } const statuses = await discoverSubscriptions(); const status = statuses.find((candidate) => candidate.descriptor.id === body.subscription_id); if (!status) { return reply.status(404).send({ success: false, error: 'subscription not found' }); } saveSettings({ subscriptions: { [status.descriptor.id]: { enabled: true, autoSpawn: body.auto_spawn ?? true, bridgeUrl: body.bridge_url ?? status.bridgeUrl ?? '', }, }, }); let spawned: RunningBridge | null = null; if (!body.bridge_url && status.installed && status.authenticated !== false && !status.bridgeRunning) { spawned = await spawnBridge(status.descriptor); } const refreshed = await discoverSubscriptions(); const refreshedStatus = refreshed.find((candidate) => candidate.descriptor.id === body.subscription_id) ?? status; const runningById = new Map(getRunningBridges().map((bridge) => [bridge.descriptor.id, bridge])); const subscription = summarizeSubscription(refreshedStatus, runningById, getRequestBaseUrl(request)); return reply.send({ success: true, data: { joined: true, spawned: spawned ? { id: spawned.descriptor.id, providerName: spawned.descriptor.providerName, url: spawned.url, port: spawned.port, startedAt: spawned.startedAt.toISOString(), } : null, subscription, access: subscription.access, }, meta: { timestamp: new Date().toISOString() }, }); } catch (error) { logger.error({ error }, 'Subscription join failed'); return reply.status(500).send({ success: false, error: 'subscription join failed' }); } }); fastify.post('/api/subscriptions/test', auth, async (request: FastifyRequest, reply: FastifyReply) => { try { const body = (request.body ?? {}) as SubscriptionTestBody; if (!body.subscription_id) { return reply.status(400).send({ success: false, error: 'subscription_id required' }); } const statuses = await discoverSubscriptions(); const status = statuses.find((candidate) => candidate.descriptor.id === body.subscription_id); if (!status) { return reply.status(404).send({ success: false, error: 'subscription not found' }); } let runningById = new Map(getRunningBridges().map((bridge) => [bridge.descriptor.id, bridge])); let url = configuredBridgeUrl(status, runningById); if (!url && status.installed && status.authenticated !== false) { const bridge = await spawnBridge(status.descriptor); url = bridge.url; runningById = new Map(getRunningBridges().map((item) => [item.descriptor.id, item])); } if (!url) { return reply.status(409).send({ success: false, error: 'bridge unavailable', data: summarizeSubscription(status, runningById, getRequestBaseUrl(request)), }); } const model = body.model ?? status.descriptor.models[0]?.id ?? status.descriptor.id; const message = body.message ?? 'Reply with exactly: gateway-bridge-ok'; const timeoutMs = Math.min(Math.max(Number(body.timeout_ms) || 30_000, 1_000), 300_000); const upstream = await postBridgeChat(url, model, message, timeoutMs); const content = contentFromBridgeResponse(upstream.data); return reply.status(upstream.ok ? 200 : 424).send({ success: upstream.ok, data: { subscription: summarizeSubscription(status, runningById, getRequestBaseUrl(request)), request: { model, messageChars: message.length, timeoutMs, }, bridge: { url, endpoint: bridgeEndpoint(url, '/v1/chat/completions'), statusCode: upstream.statusCode, latencyMs: upstream.latencyMs, }, response: { content, raw: upstream.data, }, }, meta: { timestamp: new Date().toISOString() }, }); } catch (error) { logger.error({ error }, 'Subscription bridge test failed'); return reply.status(424).send({ success: false, error: error instanceof Error ? error.message : 'subscription bridge test failed', }); } }); fastify.post('/api/subscriptions/:subscription_id/v1/chat/completions', auth, async (request: FastifyRequest, reply: FastifyReply) => { const params = request.params as SubscriptionChatParams; const status = await getSubscriptionStatusOrReply(params.subscription_id, reply); if (!status) return reply; try { await ensureSubscriptionBridge(status); const body = (request.body ?? {}) as SubscriptionChatBody; const model = typeof body.model === 'string' ? body.model : status.descriptor.models[0]?.id ?? status.descriptor.id; const payload = { ...body, model, }; const caller = request.headers['x-caller-id'] ?? `subscription-${status.descriptor.id}`; const injected = await fastify.inject({ method: 'POST', url: '/v1/chat/completions', headers: { 'content-type': 'application/json', 'x-caller-id': Array.isArray(caller) ? caller[0] : String(caller), 'x-llm-gateway-subscription': status.descriptor.id, }, payload, }); let responseBody: unknown = injected.payload; try { responseBody = JSON.parse(injected.payload); } catch { // Keep raw payload for non-JSON bridge errors. } const contentType = injected.headers['content-type']; if (typeof contentType === 'string') reply.header('content-type', contentType); return reply.status(injected.statusCode).send(responseBody); } catch (error) { logger.error({ error, subscriptionId: status.descriptor.id }, 'Subscription gateway API call failed'); return reply.status(424).send({ success: false, error: error instanceof Error ? error.message : 'subscription gateway API call failed', }); } }); }