573 lines
21 KiB
TypeScript
573 lines
21 KiB
TypeScript
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';
|
|
import { requireGatewayApiKey } from '../security/tenant-auth.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<typeof getRunningBridges>[number];
|
|
|
|
function configuredBridgeUrl(status: SubscriptionStatus, runningById: Map<string, RunningBridge>): 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 <key>',
|
|
'X-Caller-ID': 'your-service-name',
|
|
'X-LLM-Gateway-Subscription': status.descriptor.id,
|
|
},
|
|
env: {
|
|
LLM_GATEWAY_URL: baseUrl,
|
|
LLM_GATEWAY_API_KEY: '<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 dedicated gateway API key from your deployment. OAuth/subscription secrets stay inside the bridge process and are never exposed here.',
|
|
};
|
|
}
|
|
|
|
function matchesSearch(subscription: ReturnType<typeof summarizeSubscription>, 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<string, RunningBridge>, 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<SubscriptionStatus | null> {
|
|
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<RunningBridge | null> {
|
|
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<any> => {
|
|
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<void> {
|
|
const auth = { preHandler: requireDashboardAuth };
|
|
const apiAuth = { preHandler: requireGatewayApiKey };
|
|
|
|
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', apiAuth, 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',
|
|
...(request.headers.authorization ? { authorization: request.headers.authorization } : {}),
|
|
...(request.headers['x-llm-gateway-key'] ? { 'x-llm-gateway-key': request.headers['x-llm-gateway-key'] } : {}),
|
|
'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',
|
|
});
|
|
}
|
|
});
|
|
}
|