From d265320819f93d40e753fee0a1f5b1a110cd62d1 Mon Sep 17 00:00:00 2001 From: Rene Fichtmueller Date: Fri, 17 Jul 2026 19:32:05 +0200 Subject: [PATCH] feat(gateway): expose subscription bridges as gateway APIs --- packages/gateway/src/routes/completion.ts | 130 ++++++++++++++----- packages/gateway/src/routes/subscriptions.ts | 90 +++++++++++++ 2 files changed, 190 insertions(+), 30 deletions(-) diff --git a/packages/gateway/src/routes/completion.ts b/packages/gateway/src/routes/completion.ts index 2415e88..a48ee35 100644 --- a/packages/gateway/src/routes/completion.ts +++ b/packages/gateway/src/routes/completion.ts @@ -769,70 +769,127 @@ interface SubscriptionBridgeTarget { defaultUrl: string; } -const SUBSCRIPTION_BRIDGE_TARGETS_BY_MODEL: Record = { - 'claude-opus-4-1': { +const SUBSCRIPTION_BRIDGE_TARGETS_BY_ID: Record = { + 'claude-code': { subscriptionId: 'claude-code', provider: 'claude-bridge', envKey: 'CLAUDE_BRIDGE_URL', - defaultUrl: 'http://127.0.0.1:3250', + defaultUrl: 'http://localhost:3250', }, - 'claude-sonnet-4-6': { - subscriptionId: 'claude-code', - provider: 'claude-bridge', - envKey: 'CLAUDE_BRIDGE_URL', - defaultUrl: 'http://127.0.0.1:3250', - }, - 'claude-haiku-3': { - subscriptionId: 'claude-code', - provider: 'claude-bridge', - envKey: 'CLAUDE_BRIDGE_URL', - defaultUrl: 'http://127.0.0.1:3250', + 'github-copilot': { + subscriptionId: 'github-copilot', + provider: 'copilot-bridge', + envKey: 'COPILOT_BRIDGE_URL', + defaultUrl: 'http://localhost:3252', }, 'microsoft-365-copilot': { subscriptionId: 'microsoft-365-copilot', provider: 'm365-copilot-bridge', envKey: 'M365_COPILOT_BRIDGE_URL', - defaultUrl: 'http://127.0.0.1:3257', + defaultUrl: 'http://localhost:3257', + }, + 'chatgpt': { + subscriptionId: 'chatgpt', + provider: 'chatgpt-bridge', + envKey: 'CHATGPT_BRIDGE_URL', + defaultUrl: 'http://localhost:3251', + }, + 'gemini': { + subscriptionId: 'gemini', + provider: 'gemini-bridge', + envKey: 'GEMINI_BRIDGE_URL', + defaultUrl: 'http://localhost:3254', + }, + 'codex': { + subscriptionId: 'codex', + provider: 'codex-bridge', + envKey: 'CODEX_BRIDGE_URL', + defaultUrl: 'http://localhost:3253', + }, + 'aider': { + subscriptionId: 'aider', + provider: 'aider-bridge', + envKey: 'AIDER_BRIDGE_URL', + defaultUrl: 'http://localhost:3256', + }, +}; + +const SUBSCRIPTION_BRIDGE_TARGETS_BY_MODEL: Record = { + 'claude-opus-4-1': { + subscriptionId: 'claude-code', + provider: 'claude-bridge', + envKey: 'CLAUDE_BRIDGE_URL', + defaultUrl: 'http://localhost:3250', + }, + 'claude-sonnet-4-6': { + subscriptionId: 'claude-code', + provider: 'claude-bridge', + envKey: 'CLAUDE_BRIDGE_URL', + defaultUrl: 'http://localhost:3250', + }, + 'claude-haiku-3': { + subscriptionId: 'claude-code', + provider: 'claude-bridge', + envKey: 'CLAUDE_BRIDGE_URL', + defaultUrl: 'http://localhost:3250', + }, + 'gpt-4-turbo': { + subscriptionId: 'chatgpt', + provider: 'chatgpt-bridge', + envKey: 'CHATGPT_BRIDGE_URL', + defaultUrl: 'http://localhost:3251', + }, + 'microsoft-365-copilot': { + subscriptionId: 'microsoft-365-copilot', + provider: 'm365-copilot-bridge', + envKey: 'M365_COPILOT_BRIDGE_URL', + defaultUrl: 'http://localhost:3257', }, 'm365-copilot-chat': { subscriptionId: 'microsoft-365-copilot', provider: 'm365-copilot-bridge', envKey: 'M365_COPILOT_BRIDGE_URL', - defaultUrl: 'http://127.0.0.1:3257', + defaultUrl: 'http://localhost:3257', }, 'gemini-1.5-pro': { subscriptionId: 'gemini', provider: 'gemini-bridge', envKey: 'GEMINI_BRIDGE_URL', - defaultUrl: 'http://127.0.0.1:3254', + defaultUrl: 'http://localhost:3254', }, 'gemini-1.5-flash': { subscriptionId: 'gemini', provider: 'gemini-bridge', envKey: 'GEMINI_BRIDGE_URL', - defaultUrl: 'http://127.0.0.1:3254', + defaultUrl: 'http://localhost:3254', }, 'aider-default': { subscriptionId: 'aider', provider: 'aider-bridge', envKey: 'AIDER_BRIDGE_URL', - defaultUrl: 'http://127.0.0.1:3256', + defaultUrl: 'http://localhost:3256', }, }; +function subscriptionBridgeTargetForSubscriptionId(subscriptionId: string | undefined): SubscriptionBridgeTarget | null { + if (!subscriptionId) return null; + return SUBSCRIPTION_BRIDGE_TARGETS_BY_ID[subscriptionId.toLowerCase()] ?? null; +} + function subscriptionBridgeTargetForModel(model: string | undefined): SubscriptionBridgeTarget | null { if (!model) return null; if (isCodexBridgeModel(model)) { - return { - subscriptionId: 'codex', - provider: 'codex-bridge', - envKey: 'CODEX_BRIDGE_URL', - defaultUrl: 'http://127.0.0.1:3253', - }; + return subscriptionBridgeTargetForSubscriptionId('codex'); } return SUBSCRIPTION_BRIDGE_TARGETS_BY_MODEL[model.toLowerCase()] ?? null; } +function requestedSubscriptionFromHeaders(request: FastifyRequest): string | undefined { + const value = request.headers['x-llm-gateway-subscription'] + ?? request.headers['x-subscription-id']; + return Array.isArray(value) ? value[0] : value; +} + function subscriptionBridgeUrl(target: SubscriptionBridgeTarget): string { return (process.env[target.envKey] ?? target.defaultUrl).replace(/\/$/, ''); } @@ -908,9 +965,11 @@ async function postSubscriptionBridgeCompletion( async function callSubscriptionBridgeChatCompletion( body: OpenAIChatCompletionRequest, startMs: number, - callId: string + callId: string, + requestedSubscriptionId?: string ): Promise<{ ok: true; body: Record } | { ok: false; statusCode: number; error: string } | null> { - const target = subscriptionBridgeTargetForModel(body.model); + const target = subscriptionBridgeTargetForSubscriptionId(requestedSubscriptionId) + ?? subscriptionBridgeTargetForModel(body.model); if (!target) return null; const compressed = compressChatMessagesForBridge(body); @@ -952,6 +1011,7 @@ async function callSubscriptionBridgeChatCompletion( status: 'approved', provider: target.provider, subscription_id: target.subscriptionId, + subscription_forced: Boolean(requestedSubscriptionId), latency_ms: Date.now() - startMs, passthrough: true, compression: buildCompressionResponse(compressed.compression), @@ -1338,7 +1398,17 @@ export async function completionRoute(fastify: FastifyInstance): Promise { } const callId = `chatcmpl-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; - const subscriptionBridgeResult = await callSubscriptionBridgeChatCompletion(parsed.data, startMs, callId); + const requestedSubscriptionId = requestedSubscriptionFromHeaders(request); + const subscriptionBridgeResult = await callSubscriptionBridgeChatCompletion(parsed.data, startMs, callId, requestedSubscriptionId); + if (requestedSubscriptionId && !subscriptionBridgeResult) { + return reply.status(404).send({ + error: { + message: `Unknown subscription bridge: ${requestedSubscriptionId}`, + type: 'not_found', + code: 404, + }, + }); + } if (subscriptionBridgeResult) { if (!subscriptionBridgeResult.ok) { return reply.status(subscriptionBridgeResult.statusCode).send({ @@ -1431,10 +1501,10 @@ export async function completionRoute(fastify: FastifyInstance): Promise { // Codex.app sends model=gpt-5.5 / gpt-5.1-codex-mini etc. These are // ChatGPT-subscription models the openai API itself rejects without // the right auth. Route them straight to the local codex-bridge - // (PM2 process at 127.0.0.1:3253) which speaks codex-cli over OAuth. + // (local bridge process) which speaks codex-cli over OAuth. if (isCodexBridgeModel(parsed.data.model)) { try { - const bridgeUrl = process.env['CODEX_BRIDGE_URL'] ?? 'http://127.0.0.1:3253'; + const bridgeUrl = process.env['CODEX_BRIDGE_URL'] ?? 'http://localhost:3253'; const originalInputText = typeof parsed.data.input === 'string' ? parsed.data.input : (Array.isArray(parsed.data.input) diff --git a/packages/gateway/src/routes/subscriptions.ts b/packages/gateway/src/routes/subscriptions.ts index 22ae35f..11b04af 100644 --- a/packages/gateway/src/routes/subscriptions.ts +++ b/packages/gateway/src/routes/subscriptions.ts @@ -30,6 +30,19 @@ interface SubscriptionTestBody extends SubscriptionBridgeBody { 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 { @@ -67,12 +80,14 @@ function buildAccessCard( ) { 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)}`, @@ -80,6 +95,7 @@ function buildAccessCard( 'Content-Type': 'application/json', Authorization: 'Bearer ', 'X-Caller-ID': 'your-service-name', + 'X-LLM-Gateway-Subscription': status.descriptor.id, }, env: { LLM_GATEWAY_URL: baseUrl, @@ -100,6 +116,14 @@ function buildAccessCard( ` -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.', @@ -206,6 +230,27 @@ async function getSubscriptionSnapshot(request: FastifyRequest, search = '') { }; } +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 @@ -475,4 +520,49 @@ export async function subscriptionsRoute(fastify: FastifyInstance): Promise { + 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', + }); + } + }); }