feat(gateway): expose subscription bridges as gateway APIs

This commit is contained in:
Rene Fichtmueller 2026-07-17 19:32:05 +02:00
parent 812b695e1a
commit d265320819
2 changed files with 190 additions and 30 deletions

View File

@ -769,70 +769,127 @@ interface SubscriptionBridgeTarget {
defaultUrl: string;
}
const SUBSCRIPTION_BRIDGE_TARGETS_BY_MODEL: Record<string, SubscriptionBridgeTarget> = {
'claude-opus-4-1': {
const SUBSCRIPTION_BRIDGE_TARGETS_BY_ID: Record<string, SubscriptionBridgeTarget> = {
'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<string, SubscriptionBridgeTarget> = {
'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<string, unknown> } | { 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<void> {
}
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<void> {
// 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)

View File

@ -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<typeof getRunningBridges>[number];
function configuredBridgeUrl(status: SubscriptionStatus, runningById: Map<string, RunningBridge>): 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 <key>',
'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<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
@ -475,4 +520,49 @@ export async function subscriptionsRoute(fastify: FastifyInstance): Promise<void
});
}
});
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',
});
}
});
}