Skip to content

Commit 215b6b4

Browse files
authored
fix(ai-openai): migrate WebRTC realtime adapter to OpenAI GA API (#699)
* fix(ai-openai): migrate WebRTC realtime adapter to OpenAI GA API * Merge branch 'main' into fix/openai-realtime-ga-migration * fix(ai-openai): complete realtime Beta-to-GA migration Completes the GA migration started in this PR so the whole realtime flow works against OpenAI's GA API (the Beta shape was shut down 2026-05-12): - openaiRealtimeToken() mints ephemeral keys via POST /v1/realtime/client_secrets (the Beta /v1/realtime/sessions endpoint is retired) and parses the GA top-level value/expires_at response shape - session.update payloads use the GA shape via a new pure buildSessionUpdate() helper: required session.type, audio.input.*, audio.output.voice, output_modalities, max_output_tokens; temperature (removed in GA) is dropped with a debug log instead of getting the whole update rejected with unknown_parameter - server events handled under GA names (response.output_audio_transcript.*, response.output_audio.*, output_text/output_audio content parts) - removed the now-unused model local in createWebRTCConnection (the GA /calls endpoint rejects ?model=; the model is bound to the ephemeral key) - default model gpt-realtime; dead gpt-4o-(mini-)realtime-preview ids (shut down 2026-05-07) removed from OpenAIRealtimeModel, docs, and examples - unit tests for the session.update payload and client-secret request/response shapes; changeset added Live-verified against the OpenAI API: client_secrets 200 (ek_ token), /v1/realtime/calls 201 with SDP answer, and session.updated echoing voice, semantic VAD, tools, output_modalities, and max_output_tokens. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ai-openai): collapse output modalities to single GA-supported value The GA realtime API only accepts ['audio'] or ['text'] for output_modalities; the Beta API accepted ['audio', 'text'] and the provider-agnostic RealtimeSessionConfig still legitimately produces it (e.g. the example UI's audio+text mode). Sending both got the whole session.update rejected with: Invalid modalities: ['audio', 'text']. Collapse to ['audio'] when audio is requested — GA audio replies still stream text via response.output_audio_transcript.* events, so visible behavior is unchanged. Live-verified: session.updated accepted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Merge branch 'main' into fix/openai-realtime-ga-migration
1 parent e8ce0e1 commit 215b6b4

12 files changed

Lines changed: 389 additions & 152 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@tanstack/ai-openai': patch
3+
'@tanstack/ai': patch
4+
---
5+
6+
Migrate the OpenAI realtime adapters from the retired Beta API (shut down 2026-05-12) to the GA API:
7+
8+
- `openaiRealtime()` now exchanges WebRTC SDP via `POST /v1/realtime/calls` (the Beta `?model=` shape returned `beta_api_shape_disabled`).
9+
- `openaiRealtimeToken()` now mints ephemeral keys via `POST /v1/realtime/client_secrets` instead of the retired `/v1/realtime/sessions`, and parses the GA top-level `value`/`expires_at` response shape.
10+
- `session.update` payloads use the GA shape: required `session.type`, `audio.input.transcription`, `audio.input.turn_detection`, `audio.output.voice`, `output_modalities`, and `max_output_tokens`. `temperature` was removed from the GA session config and is no longer sent (a debug log notes when it is dropped).
11+
- Server events are handled under their GA names (`response.output_audio_transcript.*`, `response.output_audio.*`, `output_text`/`output_audio` content parts).
12+
- The default realtime model is now `gpt-realtime`; the `gpt-4o-(mini-)realtime-preview` ids (shut down by OpenAI on 2026-05-07) were removed from `OpenAIRealtimeModel`.

docs/media/realtime-chat.md

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ const getRealtimeToken = createServerFn({ method: 'POST' })
4848
.handler(async () => {
4949
return realtimeToken({
5050
adapter: openaiRealtimeToken({
51-
model: 'gpt-4o-realtime-preview',
51+
model: 'gpt-realtime',
5252
}),
5353
})
5454
})
@@ -119,7 +119,7 @@ import { openaiRealtimeToken } from '@tanstack/ai-openai'
119119

120120
const token = await realtimeToken({
121121
adapter: openaiRealtimeToken({
122-
model: 'gpt-4o-realtime-preview',
122+
model: 'gpt-realtime',
123123
}),
124124
})
125125
```
@@ -138,10 +138,8 @@ const adapter = openaiRealtime()
138138

139139
| Model | Description |
140140
|-------|-------------|
141-
| `gpt-4o-realtime-preview` | Full realtime model |
142-
| `gpt-4o-mini-realtime-preview` | Smaller, faster realtime model |
143-
| `gpt-realtime` | Latest realtime model |
144-
| `gpt-realtime-mini` | Latest mini realtime model |
141+
| `gpt-realtime` | Full realtime model |
142+
| `gpt-realtime-mini` | Smaller, faster realtime model |
145143

146144
**Available voices:** `alloy`, `ash`, `ballad`, `coral`, `echo`, `sage`, `shimmer`, `verse`, `marin`, `cedar`
147145

docs/reference/functions/realtimeToken.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,7 @@ export const getRealtimeToken = createServerFn()
4141
.handler(async () => {
4242
return realtimeToken({
4343
adapter: openaiRealtimeToken({
44-
model: 'gpt-4o-realtime-preview',
45-
voice: 'alloy',
46-
instructions: 'You are a helpful assistant...',
44+
model: 'gpt-realtime',
4745
}),
4846
})
4947
})

examples/ts-code-mode-web/src/routes/_execute-prompt/api.realtime-token.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export const Route = createFileRoute(
1111
try {
1212
const token = await realtimeToken({
1313
adapter: openaiRealtimeToken({
14-
model: 'gpt-4o-realtime-preview',
14+
model: 'gpt-realtime',
1515
}),
1616
})
1717
return new Response(JSON.stringify(token), {

examples/ts-react-chat/src/lib/use-realtime.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ const getRealtimeTokenFn = createServerFn({ method: 'POST' })
2020
if (data.provider === 'openai') {
2121
return realtimeToken({
2222
adapter: openaiRealtimeToken({
23-
model: 'gpt-4o-realtime-preview',
23+
model: 'gpt-realtime',
2424
}),
2525
})
2626
}

packages/ai-openai/src/realtime/adapter.ts

Lines changed: 26 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { resolveDebugOption } from '@tanstack/ai/adapter-internals'
2+
import { buildSessionUpdate } from './session-update'
23
import type {
34
AnyClientTool,
45
AudioVisualization,
@@ -47,7 +48,7 @@ export function openaiRealtime(
4748
token: RealtimeToken,
4849
_clientTools?: ReadonlyArray<AnyClientTool>,
4950
): Promise<RealtimeConnection> {
50-
const model = token.config.model ?? 'gpt-4o-realtime-preview'
51+
const model = token.config.model ?? 'gpt-realtime'
5152
logger.request(`activity=realtime provider=openai model=${model}`, {
5253
provider: 'openai',
5354
model,
@@ -73,7 +74,6 @@ async function createWebRTCConnection(
7374
token: RealtimeToken,
7475
logger: InternalLogger,
7576
): Promise<RealtimeConnection> {
76-
const model = token.config.model ?? 'gpt-4o-realtime-preview'
7777
const eventHandlers = new Map<RealtimeEvent, Set<RealtimeEventHandler<any>>>()
7878

7979
// WebRTC peer connection
@@ -187,10 +187,12 @@ async function createWebRTCConnection(
187187
const offer = await pc.createOffer()
188188
await pc.setLocalDescription(offer)
189189

190-
// Send SDP to OpenAI and get answer. `offer.sdp` is `string | undefined` per
191-
// the WebRTC type definitions; coerce to `null` (which `RequestInit.body`
192-
// accepts) under exactOptionalPropertyTypes.
193-
const sdpResponse = await fetch(`${OPENAI_REALTIME_URL}?model=${model}`, {
190+
// Send SDP to OpenAI's GA `/calls` endpoint and get the answer. The model
191+
// is bound to the ephemeral token (minted via `/v1/realtime/client_secrets`),
192+
// so it must NOT be passed as a query param — GA rejects `?model=` with a
193+
// 400. `offer.sdp` is `string | undefined` per the WebRTC type definitions;
194+
// coerce to `null`, which `RequestInit.body` accepts.
195+
const sdpResponse = await fetch(`${OPENAI_REALTIME_URL}/calls`, {
194196
method: 'POST',
195197
headers: {
196198
Authorization: `Bearer ${token.token}`,
@@ -260,7 +262,7 @@ async function createWebRTCConnection(
260262
break
261263
}
262264

263-
case 'response.audio_transcript.delta': {
265+
case 'response.output_audio_transcript.delta': {
264266
const delta = event.delta as string
265267
emit('transcript', {
266268
role: 'assistant',
@@ -270,7 +272,7 @@ async function createWebRTCConnection(
270272
break
271273
}
272274

273-
case 'response.audio_transcript.done': {
275+
case 'response.output_audio_transcript.done': {
274276
const transcript = event.transcript as string
275277
emit('transcript', { role: 'assistant', transcript, isFinal: true })
276278
break
@@ -296,14 +298,14 @@ async function createWebRTCConnection(
296298
break
297299
}
298300

299-
case 'response.audio.delta':
301+
case 'response.output_audio.delta':
300302
if (currentMode !== 'speaking') {
301303
currentMode = 'speaking'
302304
emit('mode_change', { mode: 'speaking' })
303305
}
304306
break
305307

306-
case 'response.audio.done':
308+
case 'response.output_audio.done':
307309
break
308310

309311
case 'response.function_call_arguments.done': {
@@ -359,12 +361,14 @@ async function createWebRTCConnection(
359361
if (item.type === 'message' && item.content) {
360362
const content = item.content as Array<Record<string, unknown>>
361363
for (const part of content) {
362-
if (part.type === 'audio' && part.transcript) {
364+
// GA renamed assistant content types: `audio` -> `output_audio`,
365+
// `text` -> `output_text`
366+
if (part.type === 'output_audio' && part.transcript) {
363367
message.parts.push({
364368
type: 'audio',
365369
transcript: part.transcript as string,
366370
})
367-
} else if (part.type === 'text' && part.text) {
371+
} else if (part.type === 'output_text' && part.text) {
368372
message.parts.push({
369373
type: 'text',
370374
content: part.text as string,
@@ -586,65 +590,19 @@ async function createWebRTCConnection(
586590
},
587591

588592
updateSession(config: Partial<RealtimeSessionConfig>) {
589-
const sessionUpdate: Record<string, unknown> = {}
590-
591-
if (config.instructions) {
592-
sessionUpdate.instructions = config.instructions
593-
}
594-
595-
if (config.voice) {
596-
sessionUpdate.voice = config.voice
597-
}
598-
599-
if (config.vadMode) {
600-
if (config.vadMode === 'semantic') {
601-
sessionUpdate.turn_detection = {
602-
type: 'semantic_vad',
603-
eagerness: config.semanticEagerness ?? 'medium',
604-
}
605-
} else if (config.vadMode === 'server') {
606-
sessionUpdate.turn_detection = {
607-
type: 'server_vad',
608-
threshold: config.vadConfig?.threshold ?? 0.5,
609-
prefix_padding_ms: config.vadConfig?.prefixPaddingMs ?? 300,
610-
silence_duration_ms: config.vadConfig?.silenceDurationMs ?? 500,
611-
}
612-
} else {
613-
sessionUpdate.turn_detection = null
614-
}
615-
}
616-
617-
if (config.tools !== undefined) {
618-
sessionUpdate.tools = config.tools.map((t) => ({
619-
type: 'function',
620-
name: t.name,
621-
description: t.description,
622-
parameters: t.inputSchema ?? { type: 'object', properties: {} },
623-
}))
624-
sessionUpdate.tool_choice = 'auto'
625-
}
626-
627-
if (config.outputModalities) {
628-
sessionUpdate.modalities = config.outputModalities
629-
}
630-
631593
if (config.temperature !== undefined) {
632-
sessionUpdate.temperature = config.temperature
633-
}
634-
635-
if (config.maxOutputTokens !== undefined) {
636-
sessionUpdate.max_response_output_tokens = config.maxOutputTokens
594+
// The GA API removed `temperature` from session config; sending it
595+
// would get the whole update rejected with `unknown_parameter`.
596+
logger.provider(
597+
'provider=openai direction=out type=session.update dropped `temperature` (removed in the GA realtime API)',
598+
{ frame: { temperature: config.temperature } },
599+
)
637600
}
638601

639-
// Always enable input audio transcription so user speech is transcribed
640-
sessionUpdate.input_audio_transcription = { model: 'whisper-1' }
641-
642-
if (Object.keys(sessionUpdate).length > 0) {
643-
sendEvent({
644-
type: 'session.update',
645-
session: sessionUpdate,
646-
})
647-
}
602+
sendEvent({
603+
type: 'session.update',
604+
session: buildSessionUpdate(config),
605+
})
648606
},
649607

650608
interrupt() {
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import type { RealtimeSessionConfig } from '@tanstack/ai'
2+
3+
/**
4+
* Builds the GA-shaped `session.update` payload for OpenAI's realtime API.
5+
*
6+
* The GA API requires `session.type` on every update and nests audio
7+
* settings under `audio.input` / `audio.output` (the flat Beta field names
8+
* were retired when the Beta shape was shut down on 2026-05-12). A
9+
* `session.update` containing unknown fields is rejected with
10+
* `unknown_parameter` and none of the config is applied, so the exact field
11+
* names here are load-bearing.
12+
*
13+
* `temperature` was removed from the GA session config and is intentionally
14+
* never sent; the adapter logs when it drops the option.
15+
*/
16+
export function buildSessionUpdate(
17+
config: Partial<RealtimeSessionConfig>,
18+
): Record<string, unknown> {
19+
// Always enable input audio transcription so user speech is transcribed
20+
const audioInput: Record<string, unknown> = {
21+
transcription: { model: 'whisper-1' },
22+
}
23+
24+
if (config.vadMode) {
25+
if (config.vadMode === 'semantic') {
26+
audioInput.turn_detection = {
27+
type: 'semantic_vad',
28+
eagerness: config.semanticEagerness ?? 'medium',
29+
}
30+
} else if (config.vadMode === 'server') {
31+
audioInput.turn_detection = {
32+
type: 'server_vad',
33+
threshold: config.vadConfig?.threshold ?? 0.5,
34+
prefix_padding_ms: config.vadConfig?.prefixPaddingMs ?? 300,
35+
silence_duration_ms: config.vadConfig?.silenceDurationMs ?? 500,
36+
}
37+
} else {
38+
audioInput.turn_detection = null
39+
}
40+
}
41+
42+
const audio: Record<string, unknown> = { input: audioInput }
43+
44+
if (config.voice) {
45+
audio.output = { voice: config.voice }
46+
}
47+
48+
const sessionUpdate: Record<string, unknown> = {
49+
type: 'realtime',
50+
audio,
51+
}
52+
53+
if (config.instructions) {
54+
sessionUpdate.instructions = config.instructions
55+
}
56+
57+
if (config.tools !== undefined) {
58+
sessionUpdate.tools = config.tools.map((t) => ({
59+
type: 'function',
60+
name: t.name,
61+
description: t.description,
62+
parameters: t.inputSchema ?? { type: 'object', properties: {} },
63+
}))
64+
sessionUpdate.tool_choice = 'auto'
65+
}
66+
67+
if (config.outputModalities) {
68+
// GA only supports a single output modality: ['audio'] or ['text']
69+
// (Beta accepted ['audio', 'text']). Audio replies still stream text
70+
// via `response.output_audio_transcript.*` events, so collapsing
71+
// ['audio', 'text'] to ['audio'] preserves the visible behavior.
72+
sessionUpdate.output_modalities = config.outputModalities.includes('audio')
73+
? ['audio']
74+
: ['text']
75+
}
76+
77+
if (config.maxOutputTokens !== undefined) {
78+
sessionUpdate.max_output_tokens = config.maxOutputTokens
79+
}
80+
81+
return sessionUpdate
82+
}

0 commit comments

Comments
 (0)