feat(gateway): expose subscription bridges as gateway APIs
This commit is contained in:
parent
812b695e1a
commit
d265320819
@ -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)
|
||||
|
||||
@ -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',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user