Support OpenAI Realtime SIP/SDP calls endpoint (POST /v1/realtime/calls)
Split from #53, separated out from the JSON-only Realtime session control endpoints because the transport and content shape are different.
Background
POST /v1/realtime/calls carries a raw SDP body with Content-Type: application/sdp, not JSON. This is the SIP/telephony entry point for OpenAI Realtime — used by clients that want to bridge a phone call into a Realtime model session. Searched src/ for realtime, client_secrets, realtime/calls, and application/sdp — zero hits.
The JSON-only Realtime session control endpoints (client_secrets, sessions, transcription_sessions, translations) are tracked separately; they share a handler shape with each other but not with this one.
Requested endpoint
| Method |
Path |
Content-Type |
Body |
POST |
/v1/realtime/calls |
application/sdp |
Raw SDP offer |
Expected response: SDP answer from the provider with Content-Type: application/sdp and a Location header for call control, per OpenAI's Realtime SIP/SDP contract.
Why this needs its own issue
/v1/realtime/calls shares almost no code with the JSON session control endpoints:
- Body type. All other proxy routes use
await request.json() (main.py:963, main.py:1124) or a Pydantic body model (main.py:1426, main.py:1246). This one needs await request.body() with the bytes forwarded verbatim — and the response is also raw SDP bytes, not a JSON object.
- Content-Type preservation. The handler must read
Content-Type from the inbound request and forward it (along with the body bytes) to the provider unchanged. The provider response Content-Type: application/sdp must also be preserved end-to-end.
- Logging.
RawIOLogger.log_request(...) (main.py:993, main.py:1129) takes a parsed dict. The SDP handler needs a body-bytes variant of the request log, or a separate log entry, because SDP isn't valid JSON.
- Response shape.
litellm.EmbeddingResponse(**...) and JSONResponse(content=...) won't work here — the handler must return a Response(content=..., media_type="application/sdp") and copy upstream headers (notably Location).
- Streaming. If the provider returns the SDP answer incrementally, the proxy needs to stream the body bytes too. Likely a one-shot SDP exchange, but worth confirming.
- Error mapping. The existing per-exception mapping (
main.py:1490-1513) assumes a JSON error response. For SDP, the provider's error body may be non-JSON or carry a SIP status code in a custom header.
Proposed initial implementation
Scope: thin passthrough with SDP body preservation. No SDP parsing, no transformation.
-
Route. Register @app.post("/v1/realtime/calls") in src/proxy_app/main.py. Apply verify_api_key (main.py:1429 style).
-
Body pass-through. Read the raw body with body_bytes = await request.body(). Forward body_bytes and the inbound Content-Type header verbatim to the resolved provider via RotatingClient. Do not parse the SDP. Do not apply apply_model_alias to SDP — model selection here is typically driven by a query parameter or the provider's account config, not by a request body field. Confirm with the OpenAI spec before the design step.
-
Provider resolution. For the first pass, route to a fixed provider (e.g. OpenAI) selected by an env var or per-request header, since RotatingClient doesn't currently have a non-JSON request path. Routing for arbitrary OpenAI-compatible Realtime providers comes in a follow-up.
-
Response. Return Response(content=upstream_body, status_code=upstream_status, headers=upstream_headers, media_type=upstream_content_type). Strip hop-by-hop headers (Connection, Transfer-Encoding, Content-Length) before forwarding.
-
Error mapping. Two-tier:
- If the upstream returns a JSON error body, parse and remap to OpenAI's error envelope (status code from the parsed
error.code, message from error.message).
- If the upstream returns a non-JSON error (or empty body), return the upstream status with an empty body and log the raw bytes at WARN for triage.
-
Logging. Pipe through log_request_to_console(...) for the request line + headers only (skip body bytes for SDP to avoid log bloat — SDP offers are typically 1-3 KB). If ENABLE_RAW_LOGGING is set, log the body bytes separately under RawIOLogger with a SDP-specific marker so it doesn't pollute JSON request logs.
-
Tests.
- Happy path: POST a synthetic SDP offer (
v=0\r\no=- ... m=audio ...) with Content-Type: application/sdp; assert response Content-Type is application/sdp and the response body parses as SDP.
- 415: POST with
Content-Type: application/json returns 415 Unsupported Media Type.
- 401: missing/invalid
PROXY_API_KEY.
- Header preservation: upstream returns
Location: <url> and the proxy preserves it in the response.
- Body integrity: assert
request.body() bytes equal what the provider received (use a mock upstream that echoes its input).
Open questions (decide before coding)
- Provider selection. Is the model in the SDP offer (
m=audio ... a=rtpmap ...), in a query param (?model=gpt-4o-realtime-preview), or in the provider's account config? Likely a query param based on OpenAI's docs, but verify before designing the URL shape.
- Streaming. Can the SDP answer arrive incrementally, or is it always a single response? Affects whether the proxy needs
StreamingResponse or a plain Response.
- SIP headers. Some telephony clients send
Via, From, To, Call-ID headers alongside the SDP body. Forward all of them? Or whitelist a known set?
- Auth. Realtime calls may use a different credential than chat completions (e.g. Realtime-specific OpenAI key with billing model attached). Decide whether to add a
REALTIME_API_KEY separate from per-credential PROXY_API_KEY.
Out of scope here
- JSON-only Realtime session control (
client_secrets, sessions, transcription_sessions, translations) — separate issue.
GET /v1/realtime WebSocket audio transport — separate issue.
- SDP parsing/validation — the proxy should treat SDP as opaque bytes.
References (HEAD on dev: d77a246)
src/proxy_app/main.py:1423-1488 — existing /v1/embeddings handler (closest analog for passthrough structure, but JSON-only)
src/proxy_app/main.py:178-183 — EmbeddingRequest Pydantic model (does NOT apply here)
src/proxy_app/main.py:963 — await request.json() pattern (do NOT use here)
src/proxy_app/main.py:1490-1513 — per-exception error mapping (JSON-only; needs SDP variant)
src/proxy_app/main.py:776-794 — streaming_response_wrapper (may apply if SDP answers stream)
src/rotator_library/providers/example_provider.py:326,344-345 — Realtime model alias map
Checklist for review
Support OpenAI Realtime SIP/SDP calls endpoint (
POST /v1/realtime/calls)Split from #53, separated out from the JSON-only Realtime session control endpoints because the transport and content shape are different.
Background
POST /v1/realtime/callscarries a raw SDP body withContent-Type: application/sdp, not JSON. This is the SIP/telephony entry point for OpenAI Realtime — used by clients that want to bridge a phone call into a Realtime model session. Searchedsrc/forrealtime,client_secrets,realtime/calls, andapplication/sdp— zero hits.The JSON-only Realtime session control endpoints (
client_secrets,sessions,transcription_sessions,translations) are tracked separately; they share a handler shape with each other but not with this one.Requested endpoint
POST/v1/realtime/callsapplication/sdpExpected response: SDP answer from the provider with
Content-Type: application/sdpand aLocationheader for call control, per OpenAI's Realtime SIP/SDP contract.Why this needs its own issue
/v1/realtime/callsshares almost no code with the JSON session control endpoints:await request.json()(main.py:963,main.py:1124) or a Pydantic body model (main.py:1426,main.py:1246). This one needsawait request.body()with the bytes forwarded verbatim — and the response is also raw SDP bytes, not a JSON object.Content-Typefrom the inbound request and forward it (along with the body bytes) to the provider unchanged. The provider responseContent-Type: application/sdpmust also be preserved end-to-end.RawIOLogger.log_request(...)(main.py:993,main.py:1129) takes a parsed dict. The SDP handler needs a body-bytes variant of the request log, or a separate log entry, because SDP isn't valid JSON.litellm.EmbeddingResponse(**...)andJSONResponse(content=...)won't work here — the handler must return aResponse(content=..., media_type="application/sdp")and copy upstream headers (notablyLocation).main.py:1490-1513) assumes a JSON error response. For SDP, the provider's error body may be non-JSON or carry a SIP status code in a custom header.Proposed initial implementation
Scope: thin passthrough with SDP body preservation. No SDP parsing, no transformation.
Route. Register
@app.post("/v1/realtime/calls")insrc/proxy_app/main.py. Applyverify_api_key(main.py:1429style).Body pass-through. Read the raw body with
body_bytes = await request.body(). Forwardbody_bytesand the inboundContent-Typeheader verbatim to the resolved provider viaRotatingClient. Do not parse the SDP. Do not applyapply_model_aliasto SDP — model selection here is typically driven by a query parameter or the provider's account config, not by a request body field. Confirm with the OpenAI spec before the design step.Provider resolution. For the first pass, route to a fixed provider (e.g. OpenAI) selected by an env var or per-request header, since
RotatingClientdoesn't currently have a non-JSON request path. Routing for arbitrary OpenAI-compatible Realtime providers comes in a follow-up.Response. Return
Response(content=upstream_body, status_code=upstream_status, headers=upstream_headers, media_type=upstream_content_type). Strip hop-by-hop headers (Connection,Transfer-Encoding,Content-Length) before forwarding.Error mapping. Two-tier:
error.code, message fromerror.message).Logging. Pipe through
log_request_to_console(...)for the request line + headers only (skip body bytes for SDP to avoid log bloat — SDP offers are typically 1-3 KB). IfENABLE_RAW_LOGGINGis set, log the body bytes separately underRawIOLoggerwith a SDP-specific marker so it doesn't pollute JSON request logs.Tests.
v=0\r\no=- ... m=audio ...) withContent-Type: application/sdp; assert responseContent-Typeisapplication/sdpand the response body parses as SDP.Content-Type: application/jsonreturns 415 Unsupported Media Type.PROXY_API_KEY.Location: <url>and the proxy preserves it in the response.request.body()bytes equal what the provider received (use a mock upstream that echoes its input).Open questions (decide before coding)
m=audio ... a=rtpmap ...), in a query param (?model=gpt-4o-realtime-preview), or in the provider's account config? Likely a query param based on OpenAI's docs, but verify before designing the URL shape.StreamingResponseor a plainResponse.Via,From,To,Call-IDheaders alongside the SDP body. Forward all of them? Or whitelist a known set?REALTIME_API_KEYseparate from per-credentialPROXY_API_KEY.Out of scope here
client_secrets,sessions,transcription_sessions,translations) — separate issue.GET /v1/realtimeWebSocket audio transport — separate issue.References (HEAD on
dev:d77a246)src/proxy_app/main.py:1423-1488— existing/v1/embeddingshandler (closest analog for passthrough structure, but JSON-only)src/proxy_app/main.py:178-183—EmbeddingRequestPydantic model (does NOT apply here)src/proxy_app/main.py:963—await request.json()pattern (do NOT use here)src/proxy_app/main.py:1490-1513— per-exception error mapping (JSON-only; needs SDP variant)src/proxy_app/main.py:776-794—streaming_response_wrapper(may apply if SDP answers stream)src/rotator_library/providers/example_provider.py:326,344-345— Realtime model alias mapChecklist for review
REALTIME_API_KEYseparation fromPROXY_API_KEY