diff --git a/packages/gateway/src/routes/completion.ts b/packages/gateway/src/routes/completion.ts index d3f5694..4106c39 100644 --- a/packages/gateway/src/routes/completion.ts +++ b/packages/gateway/src/routes/completion.ts @@ -735,43 +735,170 @@ function chatMessagesToPrompt(messages: OpenAIChatCompletionRequest['messages']) .trim(); } -async function callCodexBridgeChatCompletion( +interface SubscriptionBridgeTarget { + subscriptionId: string; + provider: string; + envKey: string; + defaultUrl: string; +} + +const SUBSCRIPTION_BRIDGE_TARGETS_BY_MODEL: Record = { + '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 => { + 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, startMs: number, callId: string ): Promise<{ ok: true; body: Record } | { 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); try { - const upstream = 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 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 upstream = await postSubscriptionBridgeCompletion(target, body, inputText); + if (!upstream.ok || upstream.data?.success === false) { + const error = bridgeErrorMessage(upstream.data, upstream.statusCode); + logger.warn( + { callId, model: body.model, provider: target.provider, statusCode: upstream.statusCode, error }, + 'subscription-bridge chat passthrough non-OK' + ); + return { ok: false, statusCode: 424, error }; } - const text = upstreamJson?.choices?.[0]?.message?.content - ?? upstreamJson?.content - ?? upstreamJson?.response - ?? ''; - const promptTokens = upstreamJson?.usage?.prompt_tokens ?? Math.ceil(inputText.length / 4); - const completionTokens = upstreamJson?.usage?.completion_tokens ?? Math.ceil(String(text).length / 4); + const text = contentFromSubscriptionBridge(upstream.data); + const promptTokens = upstream.data?.usage?.prompt_tokens ?? Math.ceil(inputText.length / 4); + const completionTokens = upstream.data?.usage?.completion_tokens ?? Math.ceil(String(text).length / 4); return { ok: true, body: { - id: upstreamJson?.id ?? `chatcmpl-${Date.now()}`, + id: upstream.data?.id ?? `chatcmpl-${Date.now()}`, object: 'chat.completion', created: Math.floor(Date.now() / 1000), model: body.model, @@ -789,18 +916,19 @@ async function callCodexBridgeChatCompletion( }, gateway: { status: 'approved', - provider: 'codex-bridge', + provider: target.provider, + subscription_id: target.subscriptionId, latency_ms: Date.now() - startMs, passthrough: true, }, }, }; } 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 { ok: false, - statusCode: 502, - error: err instanceof Error ? err.message : 'codex bridge unavailable', + statusCode: 424, + error: err instanceof Error ? err.message : 'subscription bridge unavailable', }; } } @@ -1175,21 +1303,21 @@ export async function completionRoute(fastify: FastifyInstance): Promise { } const callId = `chatcmpl-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; - const codexBridgeResult = await callCodexBridgeChatCompletion(parsed.data, startMs, callId); - if (codexBridgeResult) { - if (!codexBridgeResult.ok) { - return reply.status(codexBridgeResult.statusCode).send({ + const subscriptionBridgeResult = await callSubscriptionBridgeChatCompletion(parsed.data, startMs, callId); + if (subscriptionBridgeResult) { + if (!subscriptionBridgeResult.ok) { + return reply.status(subscriptionBridgeResult.statusCode).send({ error: { - message: codexBridgeResult.error, + message: subscriptionBridgeResult.error, type: 'upstream_error', - code: codexBridgeResult.statusCode, + code: subscriptionBridgeResult.statusCode, }, }); } 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); @@ -1284,7 +1412,13 @@ export async function completionRoute(fastify: FastifyInstance): Promise { 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) { 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); @@ -1349,9 +1483,24 @@ data: [DONE] } 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) { - 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, + }, + }); } } diff --git a/packages/gateway/src/routes/subscriptions.ts b/packages/gateway/src/routes/subscriptions.ts index 914c8b2..76c7fe3 100644 --- a/packages/gateway/src/routes/subscriptions.ts +++ b/packages/gateway/src/routes/subscriptions.ts @@ -223,6 +223,15 @@ async function postBridgeChat( const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); const startedAt = Date.now(); + const parseResponse = async (response: Response): Promise => { + const text = await response.text(); + try { + return JSON.parse(text); + } catch { + return { raw: text }; + } + }; + try { const response = await fetch(bridgeEndpoint(url, '/v1/chat/completions'), { method: 'POST', @@ -235,14 +244,39 @@ async function postBridgeChat( }), signal: controller.signal, }); - const text = await response.text(); - let data: any; - try { - data = JSON.parse(text); - } catch { - data = { raw: text }; + const data = await parseResponse(response); + if (response.status !== 404) { + return { statusCode: response.status, ok: response.ok, data, latencyMs: Date.now() - startedAt }; } - 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 { clearTimeout(timer); } @@ -411,7 +445,7 @@ export async function subscriptionsRoute(fastify: FastifyInstance): Promise