fix(gateway): return honest subscription bridge API status

This commit is contained in:
Rene Fichtmueller 2026-07-17 17:38:26 +02:00
parent 496975ed94
commit be8eda6430
2 changed files with 232 additions and 49 deletions

View File

@ -735,43 +735,170 @@ function chatMessagesToPrompt(messages: OpenAIChatCompletionRequest['messages'])
.trim(); .trim();
} }
async function callCodexBridgeChatCompletion( interface SubscriptionBridgeTarget {
subscriptionId: string;
provider: string;
envKey: string;
defaultUrl: string;
}
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://127.0.0.1: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',
},
'microsoft-365-copilot': {
subscriptionId: 'microsoft-365-copilot',
provider: 'm365-copilot-bridge',
envKey: 'M365_COPILOT_BRIDGE_URL',
defaultUrl: 'http://127.0.0.1:3257',
},
'm365-copilot-chat': {
subscriptionId: 'microsoft-365-copilot',
provider: 'm365-copilot-bridge',
envKey: 'M365_COPILOT_BRIDGE_URL',
defaultUrl: 'http://127.0.0.1:3257',
},
'gemini-1.5-pro': {
subscriptionId: 'gemini',
provider: 'gemini-bridge',
envKey: 'GEMINI_BRIDGE_URL',
defaultUrl: 'http://127.0.0.1:3254',
},
'gemini-1.5-flash': {
subscriptionId: 'gemini',
provider: 'gemini-bridge',
envKey: 'GEMINI_BRIDGE_URL',
defaultUrl: 'http://127.0.0.1:3254',
},
'aider-default': {
subscriptionId: 'aider',
provider: 'aider-bridge',
envKey: 'AIDER_BRIDGE_URL',
defaultUrl: 'http://127.0.0.1:3256',
},
};
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 SUBSCRIPTION_BRIDGE_TARGETS_BY_MODEL[model.toLowerCase()] ?? null;
}
function subscriptionBridgeUrl(target: SubscriptionBridgeTarget): string {
return (process.env[target.envKey] ?? target.defaultUrl).replace(/\/$/, '');
}
function contentFromSubscriptionBridge(data: any): string {
return data?.choices?.[0]?.message?.content
?? data?.content
?? data?.response
?? data?.message?.content
?? '';
}
function bridgeErrorMessage(data: any, statusCode: number): string {
const value = data?.error ?? data?.message ?? data?.raw ?? `subscription bridge HTTP ${statusCode}`;
if (typeof value === 'string') return value;
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
async function postSubscriptionBridgeCompletion(
target: SubscriptionBridgeTarget,
body: OpenAIChatCompletionRequest,
inputText: string
): Promise<{ statusCode: number; ok: boolean; data: any }> {
const parseBridgeResponse = async (response: Response): Promise<any> => {
const text = await response.text();
try {
return JSON.parse(text);
} catch {
return { raw: text };
}
};
const bridgeUrl = subscriptionBridgeUrl(target);
const chatResponse = await fetch(`${bridgeUrl}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: body.model,
messages: body.messages,
temperature: body.temperature,
max_tokens: body.max_tokens,
}),
});
const chatData: any = await parseBridgeResponse(chatResponse);
if (chatResponse.status !== 404) {
return { statusCode: chatResponse.status, ok: chatResponse.ok, data: chatData };
}
const generateResponse = await fetch(`${bridgeUrl}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: body.model,
prompt: inputText,
stream: false,
temperature: body.temperature ?? 0,
max_tokens: body.max_tokens,
}),
});
const generateData: any = await parseBridgeResponse(generateResponse);
return { statusCode: generateResponse.status, ok: generateResponse.ok, data: generateData };
}
async function callSubscriptionBridgeChatCompletion(
body: OpenAIChatCompletionRequest, body: OpenAIChatCompletionRequest,
startMs: number, startMs: number,
callId: string callId: 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> {
if (!isCodexBridgeModel(body.model)) return null; const target = subscriptionBridgeTargetForModel(body.model);
if (!target) return null;
const bridgeUrl = (process.env['CODEX_BRIDGE_URL'] ?? 'http://127.0.0.1:3253').replace(/\/$/, '');
const inputText = chatMessagesToPrompt(body.messages); const inputText = chatMessagesToPrompt(body.messages);
try { try {
const upstream = await fetch(`${bridgeUrl}/v1/chat/completions`, { const upstream = await postSubscriptionBridgeCompletion(target, body, inputText);
method: 'POST', if (!upstream.ok || upstream.data?.success === false) {
headers: { 'Content-Type': 'application/json' }, const error = bridgeErrorMessage(upstream.data, upstream.statusCode);
body: JSON.stringify({ logger.warn(
model: body.model, { callId, model: body.model, provider: target.provider, statusCode: upstream.statusCode, error },
messages: body.messages, 'subscription-bridge chat passthrough non-OK'
temperature: body.temperature, );
max_tokens: body.max_tokens, return { ok: false, statusCode: 424, error };
}),
});
const upstreamJson: any = await upstream.json().catch(() => ({}));
if (!upstream.ok || upstreamJson?.success === false) {
const error = upstreamJson?.error ?? upstream.statusText ?? 'codex bridge unavailable';
logger.warn({ callId, model: body.model, statusCode: upstream.status, error }, 'codex-bridge chat passthrough non-OK');
return { ok: false, statusCode: 502, error: String(error) };
} }
const text = upstreamJson?.choices?.[0]?.message?.content const text = contentFromSubscriptionBridge(upstream.data);
?? upstreamJson?.content const promptTokens = upstream.data?.usage?.prompt_tokens ?? Math.ceil(inputText.length / 4);
?? upstreamJson?.response const completionTokens = upstream.data?.usage?.completion_tokens ?? Math.ceil(String(text).length / 4);
?? '';
const promptTokens = upstreamJson?.usage?.prompt_tokens ?? Math.ceil(inputText.length / 4);
const completionTokens = upstreamJson?.usage?.completion_tokens ?? Math.ceil(String(text).length / 4);
return { return {
ok: true, ok: true,
body: { body: {
id: upstreamJson?.id ?? `chatcmpl-${Date.now()}`, id: upstream.data?.id ?? `chatcmpl-${Date.now()}`,
object: 'chat.completion', object: 'chat.completion',
created: Math.floor(Date.now() / 1000), created: Math.floor(Date.now() / 1000),
model: body.model, model: body.model,
@ -789,18 +916,19 @@ async function callCodexBridgeChatCompletion(
}, },
gateway: { gateway: {
status: 'approved', status: 'approved',
provider: 'codex-bridge', provider: target.provider,
subscription_id: target.subscriptionId,
latency_ms: Date.now() - startMs, latency_ms: Date.now() - startMs,
passthrough: true, passthrough: true,
}, },
}, },
}; };
} catch (err) { } catch (err) {
logger.error({ err, callId, model: body.model }, 'codex-bridge chat passthrough threw'); logger.error({ err, callId, model: body.model, provider: target.provider }, 'subscription-bridge chat passthrough threw');
return { return {
ok: false, ok: false,
statusCode: 502, statusCode: 424,
error: err instanceof Error ? err.message : 'codex bridge unavailable', error: err instanceof Error ? err.message : 'subscription bridge unavailable',
}; };
} }
} }
@ -1175,21 +1303,21 @@ 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 codexBridgeResult = await callCodexBridgeChatCompletion(parsed.data, startMs, callId); const subscriptionBridgeResult = await callSubscriptionBridgeChatCompletion(parsed.data, startMs, callId);
if (codexBridgeResult) { if (subscriptionBridgeResult) {
if (!codexBridgeResult.ok) { if (!subscriptionBridgeResult.ok) {
return reply.status(codexBridgeResult.statusCode).send({ return reply.status(subscriptionBridgeResult.statusCode).send({
error: { error: {
message: codexBridgeResult.error, message: subscriptionBridgeResult.error,
type: 'upstream_error', type: 'upstream_error',
code: codexBridgeResult.statusCode, code: subscriptionBridgeResult.statusCode,
}, },
}); });
} }
if (parsed.data.stream) { if (parsed.data.stream) {
return await streamOpenAIChatResponse(reply, codexBridgeResult.body); return await streamOpenAIChatResponse(reply, subscriptionBridgeResult.body);
} }
return reply.status(200).send(codexBridgeResult.body); return reply.status(200).send(subscriptionBridgeResult.body);
} }
const gatewayRequest = openAIRequestToGatewayRequest(parsed.data, request); const gatewayRequest = openAIRequestToGatewayRequest(parsed.data, request);
@ -1284,7 +1412,13 @@ export async function completionRoute(fastify: FastifyInstance): Promise<void> {
messages: [{ role: 'user', content: inputText }], messages: [{ role: 'user', content: inputText }],
}), }),
}); });
const upstreamJson: any = await upstream.json(); const upstreamText = await upstream.text();
let upstreamJson: any;
try {
upstreamJson = JSON.parse(upstreamText);
} catch {
upstreamJson = { raw: upstreamText };
}
if (upstream.ok && upstreamJson?.success !== false) { if (upstream.ok && upstreamJson?.success !== false) {
const text = upstreamJson?.content ?? upstreamJson?.response ?? upstreamJson?.choices?.[0]?.message?.content ?? ''; const text = upstreamJson?.content ?? upstreamJson?.response ?? upstreamJson?.choices?.[0]?.message?.content ?? '';
const respBody = toOpenAIResponsesResponse({ output: text, model: parsed.data.model, status: 'approved' }, parsed.data.model); const respBody = toOpenAIResponsesResponse({ output: text, model: parsed.data.model, status: 'approved' }, parsed.data.model);
@ -1349,9 +1483,24 @@ data: [DONE]
} }
return reply.send(respBody); return reply.send(respBody);
} }
logger.warn({ callId, model: parsed.data.model, upstreamJson }, 'codex-bridge upstream non-OK; falling back to standard pipeline'); const error = bridgeErrorMessage(upstreamJson, upstream.status);
logger.warn({ callId, model: parsed.data.model, upstreamJson }, 'codex-bridge upstream non-OK');
return reply.status(424).send({
error: {
message: error,
type: 'upstream_error',
code: 424,
},
});
} catch (err) { } catch (err) {
logger.error({ err, callId, model: parsed.data.model }, 'codex-bridge passthrough threw; falling back'); logger.error({ err, callId, model: parsed.data.model }, 'codex-bridge passthrough threw');
return reply.status(424).send({
error: {
message: err instanceof Error ? err.message : 'codex bridge unavailable',
type: 'upstream_error',
code: 424,
},
});
} }
} }

View File

@ -223,6 +223,15 @@ async function postBridgeChat(
const controller = new AbortController(); const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs); const timer = setTimeout(() => controller.abort(), timeoutMs);
const startedAt = Date.now(); 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 { try {
const response = await fetch(bridgeEndpoint(url, '/v1/chat/completions'), { const response = await fetch(bridgeEndpoint(url, '/v1/chat/completions'), {
method: 'POST', method: 'POST',
@ -235,14 +244,39 @@ async function postBridgeChat(
}), }),
signal: controller.signal, signal: controller.signal,
}); });
const text = await response.text(); const data = await parseResponse(response);
let data: any; if (response.status !== 404) {
try { return { statusCode: response.status, ok: response.ok, data, latencyMs: Date.now() - startedAt };
data = JSON.parse(text);
} catch {
data = { raw: text };
} }
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 { } finally {
clearTimeout(timer); clearTimeout(timer);
} }
@ -411,7 +445,7 @@ export async function subscriptionsRoute(fastify: FastifyInstance): Promise<void
const upstream = await postBridgeChat(url, model, message, timeoutMs); const upstream = await postBridgeChat(url, model, message, timeoutMs);
const content = contentFromBridgeResponse(upstream.data); const content = contentFromBridgeResponse(upstream.data);
return reply.status(upstream.ok ? 200 : 502).send({ return reply.status(upstream.ok ? 200 : 424).send({
success: upstream.ok, success: upstream.ok,
data: { data: {
subscription: summarizeSubscription(status, runningById, getRequestBaseUrl(request)), subscription: summarizeSubscription(status, runningById, getRequestBaseUrl(request)),
@ -435,7 +469,7 @@ export async function subscriptionsRoute(fastify: FastifyInstance): Promise<void
}); });
} catch (error) { } catch (error) {
logger.error({ error }, 'Subscription bridge test failed'); logger.error({ error }, 'Subscription bridge test failed');
return reply.status(500).send({ return reply.status(424).send({
success: false, success: false,
error: error instanceof Error ? error.message : 'subscription bridge test failed', error: error instanceof Error ? error.message : 'subscription bridge test failed',
}); });