From f6db0cd9d58edcf6938719edbca9395195e80219 Mon Sep 17 00:00:00 2001 From: Cezer Date: Tue, 7 Oct 2025 09:28:18 +0100 Subject: [PATCH 1/2] feat: add support for OpenAI Realtime and Responses API --- .../middleware/openai-proxy.middleware.ts | 25 ++- src/server/routes/proxy.route.ts | 183 +++++++++++++++++- 2 files changed, 201 insertions(+), 7 deletions(-) diff --git a/src/server/middleware/openai-proxy.middleware.ts b/src/server/middleware/openai-proxy.middleware.ts index d5956c9..05acfcd 100644 --- a/src/server/middleware/openai-proxy.middleware.ts +++ b/src/server/middleware/openai-proxy.middleware.ts @@ -20,16 +20,29 @@ export const proxyOpenAI = async ( const upstreamUrl = getUpstreamURL(provider, targetPath) const headers = prepareHeaders(provider) - let modifiedBody = body as Record - if (modifiedBody?.model === 'gpt-5-high') { - modifiedBody.model = 'gpt-5' - modifiedBody.reasoning_effort = 'high' + let requestBody: BodyInit | undefined + + if (body instanceof FormData) { + headers.delete('Content-Type') + if (targetPath.includes('/realtime/')) { + headers.set('OpenAI-Beta', 'realtime=v1') + } + requestBody = body + } else { + let modifiedBody = body as Record + if (modifiedBody?.model === 'gpt-5-high') { + modifiedBody.model = 'gpt-5' + if (targetPath !== '/v1/responses') { + modifiedBody.reasoning_effort = 'high' + } + } + requestBody = req.method === 'POST' && modifiedBody ? JSON.stringify(modifiedBody) : undefined } const response = await fetch(upstreamUrl, { method: req.method, - headers: headers, - body: req.method === 'POST' && modifiedBody ? JSON.stringify(modifiedBody) : undefined, + headers, + body: requestBody, }) const originalHeaders = response.headers diff --git a/src/server/routes/proxy.route.ts b/src/server/routes/proxy.route.ts index ad63fe9..b0fdadc 100644 --- a/src/server/routes/proxy.route.ts +++ b/src/server/routes/proxy.route.ts @@ -8,7 +8,7 @@ import { validateAPIToken } from '../middleware/validate-token.middleware' import { MiddlewareHandler, Context } from 'hono' import { Env } from '../types' -export const processRequest = async (c:Context) => { +export const processRequest = async (c: Context) => { const result = await c.req.valid('json') let model = null; if ('model' in result) { @@ -49,6 +49,128 @@ export const embeddings: MiddlewareHandler = async c => { return proxyOpenAI(c, result, provider, '/v1/embeddings') } +export const responses: MiddlewareHandler = async c => { + const { result, provider } = await processRequest(c) + return proxyOpenAI(c, result, provider, '/v1/responses') +} + +export const realtimeClientSecrets: MiddlewareHandler = async c => { + const result = await c.req.valid('json') + const sessionType = (result as any).session?.type + const model = (result as any).session?.model + + if (sessionType === 'transcription') { + const modelsController = new ModelController(c.env.redis) + const provider = await modelsController.findProviderByModel('gpt-4o-realtime-preview') + if (!provider) { + throw new HTTPException(401, { message: 'Realtime model not available' }) + } + return proxyOpenAI(c, result, provider, '/v1/realtime/client_secrets') + } + + if (!model) { + throw new HTTPException(400, { message: 'Model required in session' }) + } + + const modelsController = new ModelController(c.env.redis) + const provider = await modelsController.findProviderByModel(model) + + if (!provider) { + throw new HTTPException(401, { message: 'Model unsupported' }) + } + + return proxyOpenAI(c, result, provider, '/v1/realtime/client_secrets') +} + +export const realtimeCalls: MiddlewareHandler = async c => { + let model: string + const formData = new FormData() + + if (c.req.header('content-type')?.includes('multipart/form-data')) { + const incoming = await c.req.formData() + const sessionData = incoming.get('session') + const sdpData = incoming.get('sdp') + + if (!sessionData || !sdpData) { + throw new HTTPException(400, { message: 'Session and SDP required' }) + } + + const session = JSON.parse(sessionData.toString().split(';')[0]) + model = session.model + + const sdpText = sdpData instanceof File || sdpData instanceof Blob + ? await sdpData.text() + : sdpData.toString() + formData.set('sdp', sdpText) + formData.set('session', sessionData.toString().split(';')[0]) + } else { + model = c.req.query('model') + if (!model) { + throw new HTTPException(400, { message: 'Model required in query' }) + } + + formData.set('sdp', await c.req.text()) + formData.set('session', JSON.stringify({ model, modalities: ['audio', 'text'] })) + } + + if (!model) { + throw new HTTPException(400, { message: 'Model required' }) + } + + const modelsController = new ModelController(c.env.redis) + const provider = await modelsController.findProviderByModel(model) + + if (!provider) { + throw new HTTPException(401, { message: 'Model unsupported' }) + } + + return proxyOpenAI(c, formData, provider, '/v1/realtime/calls') +} + +export const realtimeCallAccept: MiddlewareHandler = async c => { + const result = await c.req.valid('json') + const model = (result as any).session?.model + + if (!model) { + throw new HTTPException(400, { message: 'Model required in session' }) + } + + const modelsController = new ModelController(c.env.redis) + const provider = await modelsController.findProviderByModel(model) + + if (!provider) { + throw new HTTPException(401, { message: 'Model unsupported' }) + } + + const callId = c.req.param('callId') + return proxyOpenAI(c, result, provider, `/v1/realtime/calls/${callId}/accept`) +} + +export const realtimeCallReject: MiddlewareHandler = async c => { + const result = await c.req.valid('json') + const modelsController = new ModelController(c.env.redis) + const provider = await modelsController.findProviderByModel('gpt-realtime') + + if (!provider) { + throw new HTTPException(401, { message: 'Realtime model not available' }) + } + + const callId = c.req.param('callId') + return proxyOpenAI(c, result, provider, `/v1/realtime/calls/${callId}/reject`) +} + +export const realtimeCallHangup: MiddlewareHandler = async c => { + const modelsController = new ModelController(c.env.redis) + const provider = await modelsController.findProviderByModel('gpt-realtime') + + if (!provider) { + throw new HTTPException(401, { message: 'Realtime model not available' }) + } + + const callId = c.req.param('callId') + return proxyOpenAI(c, {}, provider, `/v1/realtime/calls/${callId}/hangup`) +} + export const models: MiddlewareHandler = async c => { const redis = c.env.redis const modelController = new ModelController(redis) @@ -95,6 +217,25 @@ export const embeddingsValidator = z.object({ ]).transform(val => Array.isArray(val) ? val : [val]) }).passthrough() +export const responsesValidator = z.object({ + model: z.string().optional(), + input: z.union([ + z.string(), + z.array(z.any()) + ]).optional() +}).passthrough() + +export const realtimeClientSecretsValidator = z.object({ + session: z.object({ + type: z.enum(['realtime', 'transcription']).optional(), + model: z.string().optional() + }).passthrough().optional() +}).passthrough() + +export const realtimeCallRejectValidator = z.object({ + status_code: z.number().optional() +}).passthrough() + handler // OPENAI APIs .post( @@ -115,6 +256,40 @@ handler validateAPIToken, embeddings ) + .post( + '/v1/responses', + zValidator('json', responsesValidator), + validateAPIToken, + responses + ) + .post( + '/v1/realtime/client_secrets', + zValidator('json', realtimeClientSecretsValidator), + validateAPIToken, + realtimeClientSecrets + ) + .post( + '/v1/realtime/calls', + validateAPIToken, + realtimeCalls + ) + .post( + '/v1/realtime/calls/:callId/accept', + zValidator('json', realtimeClientSecretsValidator), + validateAPIToken, + realtimeCallAccept + ) + .post( + '/v1/realtime/calls/:callId/reject', + zValidator('json', realtimeCallRejectValidator), + validateAPIToken, + realtimeCallReject + ) + .post( + '/v1/realtime/calls/:callId/hangup', + validateAPIToken, + realtimeCallHangup + ) .get( '/v1/models', validateAPIToken, @@ -139,5 +314,11 @@ handler validateAPIToken, embeddings ) + .post( + '/deployments/*/responses', + zValidator('json', responsesValidator), + validateAPIToken, + responses + ) export default handler From 0788e0c01fa653966c202f5c1eea0ef556e3a8cc Mon Sep 17 00:00:00 2001 From: Cezer Date: Mon, 13 Oct 2025 09:42:46 +0100 Subject: [PATCH 2/2] chore: address PR review + fix failing build --- .../middleware/openai-proxy.middleware.ts | 3 - src/server/routes/anthropic-proxy.route.ts | 2 +- src/server/routes/proxy.route.ts | 152 ++++++++---------- 3 files changed, 68 insertions(+), 89 deletions(-) diff --git a/src/server/middleware/openai-proxy.middleware.ts b/src/server/middleware/openai-proxy.middleware.ts index 05acfcd..85f7ba9 100644 --- a/src/server/middleware/openai-proxy.middleware.ts +++ b/src/server/middleware/openai-proxy.middleware.ts @@ -24,9 +24,6 @@ export const proxyOpenAI = async ( if (body instanceof FormData) { headers.delete('Content-Type') - if (targetPath.includes('/realtime/')) { - headers.set('OpenAI-Beta', 'realtime=v1') - } requestBody = body } else { let modifiedBody = body as Record diff --git a/src/server/routes/anthropic-proxy.route.ts b/src/server/routes/anthropic-proxy.route.ts index 7c92250..8e8bded 100644 --- a/src/server/routes/anthropic-proxy.route.ts +++ b/src/server/routes/anthropic-proxy.route.ts @@ -7,7 +7,7 @@ import { proxyAnthropic } from '../middleware/anthropic-proxy.middleware' import { chatCompletionsValidator } from './proxy.route' const anthropicChatCompletions: MiddlewareHandler = async c => { - const result = await c.req.valid('json') + const result = (c.req as any).valid('json') console.log("result", result) return proxyAnthropic(c, result, '/v1/messages') } diff --git a/src/server/routes/proxy.route.ts b/src/server/routes/proxy.route.ts index b0fdadc..d6a2ae4 100644 --- a/src/server/routes/proxy.route.ts +++ b/src/server/routes/proxy.route.ts @@ -9,7 +9,7 @@ import { MiddlewareHandler, Context } from 'hono' import { Env } from '../types' export const processRequest = async (c: Context) => { - const result = await c.req.valid('json') + const result = (c.req as any).valid('json') let model = null; if ('model' in result) { // Get model from body @@ -34,6 +34,33 @@ export const processRequest = async (c: Context) => { } } +export const processRealtimeRequest = async ( + c: Context, + extractModel: (payload: any, c: Context) => string | undefined +) => { + const result = (c.req as any).valid('json') + const model = extractModel(result, c) + + if (!model) { + throw new HTTPException(400, { + message: 'Model missing', + }) + } + + const modelsController = new ModelController(c.env.redis) + const provider = await modelsController.findProviderByModel(model) + if (!provider) { + throw new HTTPException(401, { + message: 'Model unsupported', + }) + } + return { + result, + provider, + } +} + + export const completions: MiddlewareHandler = async c => { const { result, provider } = await processRequest(c) return proxyOpenAI(c, result, provider, '/v1/completions') @@ -55,30 +82,10 @@ export const responses: MiddlewareHandler = async c => { } export const realtimeClientSecrets: MiddlewareHandler = async c => { - const result = await c.req.valid('json') - const sessionType = (result as any).session?.type - const model = (result as any).session?.model - - if (sessionType === 'transcription') { - const modelsController = new ModelController(c.env.redis) - const provider = await modelsController.findProviderByModel('gpt-4o-realtime-preview') - if (!provider) { - throw new HTTPException(401, { message: 'Realtime model not available' }) - } - return proxyOpenAI(c, result, provider, '/v1/realtime/client_secrets') - } - - if (!model) { - throw new HTTPException(400, { message: 'Model required in session' }) - } - - const modelsController = new ModelController(c.env.redis) - const provider = await modelsController.findProviderByModel(model) - - if (!provider) { - throw new HTTPException(401, { message: 'Model unsupported' }) - } - + const { result, provider } = await processRealtimeRequest( + c, + payload => (payload as any).session?.model + ) return proxyOpenAI(c, result, provider, '/v1/realtime/client_secrets') } @@ -92,31 +99,29 @@ export const realtimeCalls: MiddlewareHandler = async c => { const sdpData = incoming.get('sdp') if (!sessionData || !sdpData) { - throw new HTTPException(400, { message: 'Session and SDP required' }) + throw new HTTPException(400, { message: 'Invalid realtime payload' }) } - const session = JSON.parse(sessionData.toString().split(';')[0]) + const rawSession = + typeof sessionData === 'string' + ? sessionData + : await (sessionData as Blob).text() + + const session = JSON.parse(rawSession.split(';')[0]) model = session.model - const sdpText = sdpData instanceof File || sdpData instanceof Blob - ? await sdpData.text() - : sdpData.toString() + const sdpText = + typeof sdpData === 'string' + ? sdpData + : await (sdpData as Blob).text() formData.set('sdp', sdpText) - formData.set('session', sessionData.toString().split(';')[0]) + formData.set('session', rawSession.split(';')[0]) } else { - model = c.req.query('model') - if (!model) { - throw new HTTPException(400, { message: 'Model required in query' }) - } - + model = c.req.query('model') as string formData.set('sdp', await c.req.text()) formData.set('session', JSON.stringify({ model, modalities: ['audio', 'text'] })) } - if (!model) { - throw new HTTPException(400, { message: 'Model required' }) - } - const modelsController = new ModelController(c.env.redis) const provider = await modelsController.findProviderByModel(model) @@ -128,47 +133,30 @@ export const realtimeCalls: MiddlewareHandler = async c => { } export const realtimeCallAccept: MiddlewareHandler = async c => { - const result = await c.req.valid('json') - const model = (result as any).session?.model - - if (!model) { - throw new HTTPException(400, { message: 'Model required in session' }) - } - - const modelsController = new ModelController(c.env.redis) - const provider = await modelsController.findProviderByModel(model) - - if (!provider) { - throw new HTTPException(401, { message: 'Model unsupported' }) - } - - const callId = c.req.param('callId') + const { result, provider } = await processRealtimeRequest( + c, + payload => (payload as any).model + ) + const callId = (c.req.param() as Record).callId return proxyOpenAI(c, result, provider, `/v1/realtime/calls/${callId}/accept`) } export const realtimeCallReject: MiddlewareHandler = async c => { - const result = await c.req.valid('json') - const modelsController = new ModelController(c.env.redis) - const provider = await modelsController.findProviderByModel('gpt-realtime') - - if (!provider) { - throw new HTTPException(401, { message: 'Realtime model not available' }) - } - - const callId = c.req.param('callId') + const { result, provider } = await processRealtimeRequest( + c, + payload => (payload as any).model + ) + const callId = (c.req.param() as Record).callId return proxyOpenAI(c, result, provider, `/v1/realtime/calls/${callId}/reject`) } export const realtimeCallHangup: MiddlewareHandler = async c => { - const modelsController = new ModelController(c.env.redis) - const provider = await modelsController.findProviderByModel('gpt-realtime') - - if (!provider) { - throw new HTTPException(401, { message: 'Realtime model not available' }) - } - - const callId = c.req.param('callId') - return proxyOpenAI(c, {}, provider, `/v1/realtime/calls/${callId}/hangup`) + const { result, provider } = await processRealtimeRequest( + c, + payload => (payload as any).model + ) + const callId = (c.req.param() as Record).callId + return proxyOpenAI(c, result, provider, `/v1/realtime/calls/${callId}/hangup`) } export const models: MiddlewareHandler = async c => { @@ -225,15 +213,8 @@ export const responsesValidator = z.object({ ]).optional() }).passthrough() -export const realtimeClientSecretsValidator = z.object({ - session: z.object({ - type: z.enum(['realtime', 'transcription']).optional(), - model: z.string().optional() - }).passthrough().optional() -}).passthrough() - -export const realtimeCallRejectValidator = z.object({ - status_code: z.number().optional() +export const realtimeValidator = z.object({ + model: z.string().optional() }).passthrough() handler @@ -264,7 +245,7 @@ handler ) .post( '/v1/realtime/client_secrets', - zValidator('json', realtimeClientSecretsValidator), + zValidator('json', realtimeValidator), validateAPIToken, realtimeClientSecrets ) @@ -275,18 +256,19 @@ handler ) .post( '/v1/realtime/calls/:callId/accept', - zValidator('json', realtimeClientSecretsValidator), + zValidator('json', realtimeValidator), validateAPIToken, realtimeCallAccept ) .post( '/v1/realtime/calls/:callId/reject', - zValidator('json', realtimeCallRejectValidator), + zValidator('json', realtimeValidator), validateAPIToken, realtimeCallReject ) .post( '/v1/realtime/calls/:callId/hangup', + zValidator('json', realtimeValidator), validateAPIToken, realtimeCallHangup )