Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -527,7 +527,7 @@ const rt = new OpenAIRealtimeWebSocket({ model: 'gpt-realtime-2' });
rt.on('response.output_text.delta', (event) => process.stdout.write(event.delta));
```

For more information see [docs/realtime.md](docs/realtime.md).
Realtime model sessions can opt in to reconnection with `reconnect: true`. Each reconnection starts a new server session, so your application must restore its state. See [the Realtime guide](docs/realtime.md) for connection options and session restoration.

## Microsoft Azure OpenAI

Expand Down
66 changes: 41 additions & 25 deletions docs/realtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,31 +158,47 @@ rt.on('error', (err) => {

### Disconnection and restoring a session

Realtime does not automatically retry or reconnect. That retry bound is zero:
an SDK `error` from a protocol rejection can leave the same socket usable,
while a raw socket `close` (including a transport failure) ends that connection.
Do not keep retrying permission or authentication failures. Check
`response.done` to learn the response's outcome; a close alone cannot tell
you whether a request completed.

Only your application can decide whether to create a new session. Construct a
new client socket, or use `OpenAIRealtimeWS.create(client, options)` (and
`OpenAIRealtimeWebSocket.create` for native WebSockets) for a function-based
credential. Each factory call resolves the current credential anew. For Azure,
call the appropriate `.azure(client, options)` factory again. Register new
listeners and wait for the new socket's `open` before sending new session
configuration. Neither session settings nor conversation context are restored
automatically. Remove unwanted listeners with `rt.off(event, originalListener)`;
it detaches that listener only.

`send()` returns `void` and writes to the current raw socket: it is not a
durable pre-open queue. Wait for `open`; report send/serialization errors via
an `error` listener. If your application queues commands, it owns that queue.
It may choose what to do with commands it never attempted. A command already
passed to `send()` is delivery-uncertain after a disconnect and must not be
replayed automatically. The SDK does not retry or transfer it to the new
socket. These rules apply to stable and beta Realtime; other WebSocket
endpoints can have different contracts.
By default, Realtime does not retry or reconnect. A protocol `error` can leave
the same socket usable, while a socket `close` ends that connection. Do not keep
retrying permission or authentication failures. Check `response.done` to learn
whether a response completed; a close alone cannot tell you its outcome.

To create a new connection yourself, call `OpenAIRealtimeWS.create(client, options)`
or `OpenAIRealtimeWebSocket.create(client, options)`. Each call resolves the current
credential again. For Azure, call the appropriate `.azure(client, options)` factory.
Register listeners on the new connection and wait for `open` before sending.
Remove unwanted listeners with `rt.off(event, originalListener)`.

The helpers imported from `openai/realtime/ws` and `openai/realtime/websocket` also
support `reconnect: true` for model sessions. This option does not support
transcription sessions or connections to an existing call. Helpers imported from
`openai/beta/realtime/ws` and `openai/beta/realtime/websocket` keep their existing
behavior and do not reconnect.

With reconnection enabled, the SDK keeps the same connection object but replaces
its `.socket`. Each replacement starts a new server session and refreshes
credentials. Listeners attached directly to the old socket do not move to the
new one. Use the connection's `reconnecting`, `reconnected`, and `close` events.

The SDK allows five reconnect attempts in total by default. Set
`maxReconnectAttempts` to change the limit. Delays start at 500 ms and grow to
8 seconds; use `reconnectInitialDelay` and `reconnectMaxDelay` to change them.
Calling `close()` stops retries and prevents a pending credential refresh from
opening another socket.

Wait for the initial `open` before sending. During reconnection, the SDK rejects
sends by default. Set `maxQueueBytes` to retain unsent commands up to that byte
limit. After `reconnected`, restore any session settings and conversation state
your application needs, then call `flushPending()` to send the retained commands
in order. Pause other application sends during restoration. To remove retained
commands without sending them, call `takePending()`. The final `close` event
includes remaining unsent commands as its third argument.

`send()` returns `void`; handle send and serialization errors with an `error`
listener. The SDK never resends a command it already tried to send. A successful
send does not confirm receipt or processing by the server, and a disconnect can
leave that outcome unknown. The pending queue stays in memory; it is not durable
storage. The SDK does not restore server-side state automatically.

## Realtime translation

Expand Down
1 change: 1 addition & 0 deletions scripts/test-packed-package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ const packedPackagePath = require('node:path');
" on(event: 'message', listener: (data: ArrayBuffer | Uint8Array | Uint8Array[], isBinary: boolean) => void): this;",
" on(event: 'error', listener: (error: Error) => void): this;",
" on(event: 'redirect', listener: (url: string, request: { getHeaders(): Record<string, string | string[] | undefined> }) => void): this;",
" on(event: 'unexpected-response', listener: (request: unknown, response: { statusCode?: number; resume(): void }) => void): this;",
' on(event: string, listener: (...args: unknown[]) => void): this;',
' removeListener(event: string, listener: (...args: unknown[]) => void): this;',
' }',
Expand Down
118 changes: 118 additions & 0 deletions src/internal/realtime-recovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { WebSocketConnection } from './ws-connection-controller';
import type { WebSocketLike } from './ws-adapter';
import { isRecoverableClose } from './ws';
import type { ReconnectingEvent, UnsentMessage } from './ws';

const MAX_TIMER_DELAY_MS = 2_147_483_647;

/** Optional transport recovery. Reconnecting creates a new session; it does not restore server state. */
export interface RealtimeRecoveryOptions {
/** Enable automatic transport recovery. Default: false. */
reconnect?: boolean | undefined;
/** Additional attempts over the connection's entire lifetime. Default: 5. */
maxReconnectAttempts?: number | undefined;
/** Initial retry delay in milliseconds. Default: 500. */
reconnectInitialDelay?: number | undefined;
/** Maximum retry delay in milliseconds. Default: 8000. */
reconnectMaxDelay?: number | undefined;
/** Bytes retained while disconnected. Default: 0 (reject instead of retaining). */
maxQueueBytes?: number | undefined;
}

/** Validate opt-in settings before opening a socket. */
export function validateRealtimeRecovery(options: RealtimeRecoveryOptions, url?: URL): void {
if (options.reconnect && (url?.searchParams.has('intent') || url?.searchParams.has('call_id'))) {
throw new Error('Automatic Realtime recovery requires a model session');
}
const delays = [options.reconnectInitialDelay, options.reconnectMaxDelay];
for (const value of [options.maxReconnectAttempts, ...delays, options.maxQueueBytes]) {
if (value !== undefined && (!Number.isSafeInteger(value) || value < 0)) {
throw new Error('Realtime recovery limits must be nonnegative safe integers');
}
}
if (delays.some((delay) => delay !== undefined && delay > MAX_TIMER_DELAY_MS)) {
throw new Error(`Realtime reconnect delays must not exceed ${MAX_TIMER_DELAY_MS} ms`);
}
}

/** Adapts the existing protocol controller to Realtime's explicit restoration/flush policy. */
export function createRealtimeRecovery<
TSocket extends WebSocketLike & { readonly platformSocket: unknown },
TEvent,
>(
owner: { socket: TSocket['platformSocket']; url: URL },
options: RealtimeRecoveryOptions,
hooks: {
buildURL: () => URL;
adaptSocket: (socket: TSocket['platformSocket']) => TSocket;
prepare: () => Promise<(url: URL) => TSocket['platformSocket']>;
onError: (message: string, cause?: unknown) => void;
onReconnecting: (event: ReconnectingEvent) => void;
onReconnected: () => void;
shouldReconnect?: (code: number) => boolean;
onClose: (code: number, reason: string, unsent: UnsentMessage<TEvent>[]) => void;
},
): WebSocketConnection<TSocket, TEvent, never, Error> {
const first = hooks.adaptSocket(owner.socket);
let currentSocket = first;
const controllerOwner = {
get socket() {
return currentSocket;
},
set socket(socket: TSocket) {
currentSocket = socket;
owner.socket = socket.platformSocket;
},
get url() {
return owner.url;
},
set url(url: URL) {
owner.url = url;
},
};
const controller = new WebSocketConnection<TSocket, TEvent, never, Error>(
controllerOwner,
{
buildURL: hooks.buildURL,
createSocket: () => first,
prepareReconnectSocket: async () => {
const createSocket = await hooks.prepare();
return (url) => hooks.adaptSocket(createSocket(url));
},
parameters: undefined,
isErrorEvent: () => false,
onMessage: () => {
/* The Realtime wrapper owns decoding and event dispatch. */
},
onError: hooks.onError,
createError: (message) => new Error(message),
subscribe: () => {
/* Realtime exposes its existing event emitter, not the controller iterator. */
},
unsubscribe: () => {
/* Realtime exposes its existing event emitter, not the controller iterator. */
},
emitReconnecting: hooks.onReconnecting,
emitReconnected: hooks.onReconnected,
emitClose: hooks.onClose,
requeueFailed: false,
manualQueue: true,
lifetimeRetries: true,
shouldReconnect: (code) =>
code !== 1015 && isRecoverableClose(code) && (hooks.shouldReconnect?.(code) ?? true),
},
{
maxQueueSize: options.maxQueueBytes ?? 0,
reconnect: {
onReconnecting: () => {
/* The boolean enables recovery without a required user callback. */
},
maxRetries: options.maxReconnectAttempts ?? 5,
initialDelay: options.reconnectInitialDelay ?? 500,
maxDelay: options.reconnectMaxDelay ?? 8000,
},
},
);
controller.connect(() => first, owner.url);
return controller;
}
12 changes: 7 additions & 5 deletions src/internal/ws-adapter-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ interface WebSocket {
binaryType: string;

/** Sends a text or binary WebSocket frame. */
send(data: string | ArrayBufferLike | ArrayBufferView): void;
send(data: string | ArrayBufferLike | ArrayBufferView | Blob): void;

/** Initiates a closing handshake with an optional status code and reason. */
close(code?: number, reason?: string): void;
Expand All @@ -39,18 +39,20 @@ interface CloseEvent {
/**
* Adapts a browser-native WebSocket to the SDK's platform-neutral event API.
*
* Binary messages are received as `ArrayBuffer` values, browser event objects
* Binary messages are received as `ArrayBuffer` values by default, browser event objects
* are converted into positional listener arguments, and browser errors are
* normalized to `Error` instances.
*/
export class BrowserWebSocket implements WebSocketLike {
private _ws: WebSocket;
private _listenerMap = new Map<string, Map<Listener, DOMEventHandler[]>>();

/** Wraps an existing browser socket and configures binary frames as array buffers. */
constructor(ws: WebSocket) {
/** Wraps an existing browser socket, preserving its binary mode for lifecycle-only use if requested. */
constructor(ws: WebSocket, options?: { preserveBinaryType?: boolean }) {
this._ws = ws;
this._ws.binaryType = 'arraybuffer';
if (!options?.preserveBinaryType) {
this._ws.binaryType = 'arraybuffer';
}
}

/** The browser-native socket; accessing it makes calling code platform-specific. */
Expand Down
5 changes: 5 additions & 0 deletions src/internal/ws-adapter-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,11 @@ export class NodeWebSocket implements WebSocketLike {
this._ws.close(code, reason);
}

/** Forcibly closes the underlying transport. */
terminate(): void {
this._ws.terminate();
}

/** Registers a listener with normalized message payloads and close reasons. */
on(event: string, listener: Listener): void {
const wrapped = NodeWebSocket._wrapListener(event, listener);
Expand Down
3 changes: 3 additions & 0 deletions src/internal/ws-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ export interface WebSocketLike {
/** Initiates the closing handshake, optionally providing a close code and reason. */
close(code?: number, reason?: string): void;

/** Forcibly closes the transport when a graceful close fails, if the platform supports it. */
terminate?(): void;

/** Registers a listener that runs when the socket connection opens. */
on(event: 'open', listener: () => void): void;

Expand Down
Loading
Loading