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;
|
defaultUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SUBSCRIPTION_BRIDGE_TARGETS_BY_MODEL: Record<string, SubscriptionBridgeTarget> = {
|
const SUBSCRIPTION_BRIDGE_TARGETS_BY_ID: Record<string, SubscriptionBridgeTarget> = {
|
||||||
'claude-opus-4-1': {
|
'claude-code': {
|
||||||
subscriptionId: 'claude-code',
|
subscriptionId: 'claude-code',
|
||||||
provider: 'claude-bridge',
|
provider: 'claude-bridge',
|
||||||
envKey: 'CLAUDE_BRIDGE_URL',
|
envKey: 'CLAUDE_BRIDGE_URL',
|
||||||
defaultUrl: 'http://127.0.0.1:3250',
|
defaultUrl: 'http://localhost:3250',
|
||||||
},
|
},
|
||||||
'claude-sonnet-4-6': {
|
'github-copilot': {
|
||||||
subscriptionId: 'claude-code',
|
subscriptionId: 'github-copilot',
|
||||||
provider: 'claude-bridge',
|
provider: 'copilot-bridge',
|
||||||
envKey: 'CLAUDE_BRIDGE_URL',
|
envKey: 'COPILOT_BRIDGE_URL',
|
||||||
defaultUrl: 'http://127.0.0.1:3250',
|
defaultUrl: 'http://localhost:3252',
|
||||||
},
|
|
||||||
'claude-haiku-3': {
|
|
||||||
subscriptionId: 'claude-code',
|
|
||||||
provider: 'claude-bridge',
|
|
||||||
envKey: 'CLAUDE_BRIDGE_URL',
|
|
||||||
defaultUrl: 'http://127.0.0.1:3250',
|
|
||||||
},
|
},
|
||||||
'microsoft-365-copilot': {
|
'microsoft-365-copilot': {
|
||||||
subscriptionId: 'microsoft-365-copilot',
|
subscriptionId: 'microsoft-365-copilot',
|
||||||
provider: 'm365-copilot-bridge',
|
provider: 'm365-copilot-bridge',
|
||||||
envKey: 'M365_COPILOT_BRIDGE_URL',
|
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': {
|
'm365-copilot-chat': {
|
||||||
subscriptionId: 'microsoft-365-copilot',
|
subscriptionId: 'microsoft-365-copilot',
|
||||||
provider: 'm365-copilot-bridge',
|
provider: 'm365-copilot-bridge',
|
||||||
envKey: 'M365_COPILOT_BRIDGE_URL',
|
envKey: 'M365_COPILOT_BRIDGE_URL',
|
||||||
defaultUrl: 'http://127.0.0.1:3257',
|
defaultUrl: 'http://localhost:3257',
|
||||||
},
|
},
|
||||||
'gemini-1.5-pro': {
|
'gemini-1.5-pro': {
|
||||||
subscriptionId: 'gemini',
|
subscriptionId: 'gemini',
|
||||||
provider: 'gemini-bridge',
|
provider: 'gemini-bridge',
|
||||||
envKey: 'GEMINI_BRIDGE_URL',
|
envKey: 'GEMINI_BRIDGE_URL',
|
||||||
defaultUrl: 'http://127.0.0.1:3254',
|
defaultUrl: 'http://localhost:3254',
|
||||||
},
|
},
|
||||||
'gemini-1.5-flash': {
|
'gemini-1.5-flash': {
|
||||||
subscriptionId: 'gemini',
|
subscriptionId: 'gemini',
|
||||||
provider: 'gemini-bridge',
|
provider: 'gemini-bridge',
|
||||||
envKey: 'GEMINI_BRIDGE_URL',
|
envKey: 'GEMINI_BRIDGE_URL',
|
||||||
defaultUrl: 'http://127.0.0.1:3254',
|
defaultUrl: 'http://localhost:3254',
|
||||||
},
|
},
|
||||||
'aider-default': {
|
'aider-default': {
|
||||||
subscriptionId: 'aider',
|
subscriptionId: 'aider',
|
||||||
provider: 'aider-bridge',
|
provider: 'aider-bridge',
|
||||||
envKey: 'AIDER_BRIDGE_URL',
|
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 {
|
function subscriptionBridgeTargetForModel(model: string | undefined): SubscriptionBridgeTarget | null {
|
||||||
if (!model) return null;
|
if (!model) return null;
|
||||||
if (isCodexBridgeModel(model)) {
|
if (isCodexBridgeModel(model)) {
|
||||||
return {
|
return subscriptionBridgeTargetForSubscriptionId('codex');
|
||||||
subscriptionId: 'codex',
|
|
||||||
provider: 'codex-bridge',
|
|
||||||
envKey: 'CODEX_BRIDGE_URL',
|
|
||||||
defaultUrl: 'http://127.0.0.1:3253',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return SUBSCRIPTION_BRIDGE_TARGETS_BY_MODEL[model.toLowerCase()] ?? null;
|
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 {
|
function subscriptionBridgeUrl(target: SubscriptionBridgeTarget): string {
|
||||||
return (process.env[target.envKey] ?? target.defaultUrl).replace(/\/$/, '');
|
return (process.env[target.envKey] ?? target.defaultUrl).replace(/\/$/, '');
|
||||||
}
|
}
|
||||||
@ -908,9 +965,11 @@ async function postSubscriptionBridgeCompletion(
|
|||||||
async function callSubscriptionBridgeChatCompletion(
|
async function callSubscriptionBridgeChatCompletion(
|
||||||
body: OpenAIChatCompletionRequest,
|
body: OpenAIChatCompletionRequest,
|
||||||
startMs: number,
|
startMs: number,
|
||||||
callId: string
|
callId: string,
|
||||||
|
requestedSubscriptionId?: string
|
||||||
): Promise<{ ok: true; body: Record<string, unknown> } | { ok: false; statusCode: number; error: string } | null> {
|
): 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;
|
if (!target) return null;
|
||||||
|
|
||||||
const compressed = compressChatMessagesForBridge(body);
|
const compressed = compressChatMessagesForBridge(body);
|
||||||
@ -952,6 +1011,7 @@ async function callSubscriptionBridgeChatCompletion(
|
|||||||
status: 'approved',
|
status: 'approved',
|
||||||
provider: target.provider,
|
provider: target.provider,
|
||||||
subscription_id: target.subscriptionId,
|
subscription_id: target.subscriptionId,
|
||||||
|
subscription_forced: Boolean(requestedSubscriptionId),
|
||||||
latency_ms: Date.now() - startMs,
|
latency_ms: Date.now() - startMs,
|
||||||
passthrough: true,
|
passthrough: true,
|
||||||
compression: buildCompressionResponse(compressed.compression),
|
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 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) {
|
||||||
if (!subscriptionBridgeResult.ok) {
|
if (!subscriptionBridgeResult.ok) {
|
||||||
return reply.status(subscriptionBridgeResult.statusCode).send({
|
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
|
// Codex.app sends model=gpt-5.5 / gpt-5.1-codex-mini etc. These are
|
||||||
// ChatGPT-subscription models the openai API itself rejects without
|
// ChatGPT-subscription models the openai API itself rejects without
|
||||||
// the right auth. Route them straight to the local codex-bridge
|
// 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)) {
|
if (isCodexBridgeModel(parsed.data.model)) {
|
||||||
try {
|
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'
|
const originalInputText = typeof parsed.data.input === 'string'
|
||||||
? parsed.data.input
|
? parsed.data.input
|
||||||
: (Array.isArray(parsed.data.input)
|
: (Array.isArray(parsed.data.input)
|
||||||
|
|||||||
@ -30,6 +30,19 @@ interface SubscriptionTestBody extends SubscriptionBridgeBody {
|
|||||||
timeout_ms?: number;
|
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];
|
type RunningBridge = ReturnType<typeof getRunningBridges>[number];
|
||||||
|
|
||||||
function configuredBridgeUrl(status: SubscriptionStatus, runningById: Map<string, RunningBridge>): string | null {
|
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 defaultModel = status.descriptor.models[0]?.id ?? status.descriptor.id;
|
||||||
const modelList = status.descriptor.models.map((model) => model.id);
|
const modelList = status.descriptor.models.map((model) => model.id);
|
||||||
|
const subscriptionApiUrl = `${baseUrl}/api/subscriptions/${encodeURIComponent(status.descriptor.id)}/v1/chat/completions`;
|
||||||
return {
|
return {
|
||||||
kind: 'gateway-subscription-access',
|
kind: 'gateway-subscription-access',
|
||||||
subscriptionId: status.descriptor.id,
|
subscriptionId: status.descriptor.id,
|
||||||
providerName: status.descriptor.providerName,
|
providerName: status.descriptor.providerName,
|
||||||
gatewayBaseUrl: baseUrl,
|
gatewayBaseUrl: baseUrl,
|
||||||
chatCompletionsUrl: `${baseUrl}/v1/chat/completions`,
|
chatCompletionsUrl: `${baseUrl}/v1/chat/completions`,
|
||||||
|
subscriptionChatCompletionsUrl: subscriptionApiUrl,
|
||||||
responsesUrl: `${baseUrl}/v1/responses`,
|
responsesUrl: `${baseUrl}/v1/responses`,
|
||||||
modelsUrl: `${baseUrl}/v1/models`,
|
modelsUrl: `${baseUrl}/v1/models`,
|
||||||
subscriptionStatusUrl: `${baseUrl}/api/subscriptions?search=${encodeURIComponent(status.descriptor.id)}`,
|
subscriptionStatusUrl: `${baseUrl}/api/subscriptions?search=${encodeURIComponent(status.descriptor.id)}`,
|
||||||
@ -80,6 +95,7 @@ function buildAccessCard(
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: 'Bearer <key>',
|
Authorization: 'Bearer <key>',
|
||||||
'X-Caller-ID': 'your-service-name',
|
'X-Caller-ID': 'your-service-name',
|
||||||
|
'X-LLM-Gateway-Subscription': status.descriptor.id,
|
||||||
},
|
},
|
||||||
env: {
|
env: {
|
||||||
LLM_GATEWAY_URL: baseUrl,
|
LLM_GATEWAY_URL: baseUrl,
|
||||||
@ -100,6 +116,14 @@ function buildAccessCard(
|
|||||||
` -H "Content-Type: application/json"`,
|
` -H "Content-Type: application/json"`,
|
||||||
` -H "Authorization: Bearer $LLM_GATEWAY_API_KEY"`,
|
` -H "Authorization: Bearer $LLM_GATEWAY_API_KEY"`,
|
||||||
` -H "X-Caller-ID: pilot-client"`,
|
` -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"}]}'`,
|
` -d '{"model":"${defaultModel}","messages":[{"role":"user","content":"gateway bridge smoke"}]}'`,
|
||||||
].join(' \\\n'),
|
].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.',
|
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 {
|
function contentFromBridgeResponse(data: any): string {
|
||||||
return data?.choices?.[0]?.message?.content
|
return data?.choices?.[0]?.message?.content
|
||||||
?? data?.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