Skip to content

Commit f5d4ade

Browse files
committed
feat(agents-extensions): Cloudflare transport for Workers via fetch upgrade\n\n- Add CloudflareRealtimeTransportLayer (factory via createWebSocket; skip open listener).\n- Add tests for Cloudflare transport; remove legacy fetch-upgrade tests in agents-realtime.\n- Update docs: transport guide -> use extension; troubleshooting; new Cloudflare extension page; example snippet.\n- Add concise JSDoc with Cloudflare Response API reference.\n- Add Twilio transport JSDoc reference.
1 parent 3ce9513 commit f5d4ade

File tree

10 files changed

+314
-25
lines changed

10 files changed

+314
-25
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@openai/agents-realtime': minor
3+
---
4+
5+
feat: add fetch-based WebSocket upgrade for Cloudflare/workerd via `useWorkersFetchUpgrade` and `connectViaFetchUpgrade()`. Defaults remain unchanged for other environments.

docs/astro.config.mjs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,14 @@ const sidebar = [
309309
zh: '将实时智能体连接到 Twilio',
310310
},
311311
},
312+
{
313+
label: 'Cloudflare Workers Transport',
314+
link: '/extensions/cloudflare',
315+
translations: {
316+
ja: 'Cloudflare Workers 用トランスポート',
317+
zh: 'Cloudflare Workers 传输',
318+
},
319+
},
312320
],
313321
},
314322
{
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
title: Using Realtime Agents on Cloudflare Workers
3+
description: Connect your Agents SDK agents from Cloudflare Workers/workerd using a dedicated transport.
4+
---
5+
6+
import { Aside, Steps, Code } from '@astrojs/starlight/components';
7+
import cloudflareBasicExample from '../../../../../examples/docs/extensions/cloudflare-basic.ts?raw';
8+
9+
Cloudflare Workers and other workerd runtimes cannot open outbound WebSockets using the global
10+
`WebSocket` constructor. To simplify connecting Realtime Agents from these environments, the
11+
extensions package provides a dedicated transport that performs the `fetch()`-based upgrade
12+
internally.
13+
14+
<Aside type="caution">
15+
This adapter is still in beta. You may run into edge case issues or bugs.
16+
Please report any issues via [GitHub
17+
issues](https://github.com/openai/openai-agents-js/issues) and we'll fix
18+
quickly. For Node.js-style APIs in Workers, consider enabling `nodejs_compat`.
19+
</Aside>
20+
21+
## Setup
22+
23+
<Steps>
24+
25+
1. **Install the extensions package.**
26+
27+
```bash
28+
npm install @openai/agents-extensions
29+
```
30+
31+
2. **Create a transport and attach it to your session.**
32+
33+
<Code lang="typescript" code={cloudflareBasicExample} />
34+
35+
3. **Connect your `RealtimeSession`.**
36+
37+
```typescript
38+
await session.connect({ apiKey: 'your-openai-ephemeral-or-server-key' });
39+
```
40+
41+
</Steps>
42+
43+
## Notes
44+
45+
- The Cloudflare transport uses `fetch()` with `Upgrade: websocket` under the hood and skips
46+
waiting for a socket `open` event, matching the workerd APIs.
47+
- All `RealtimeSession` features (tools, guardrails, etc.) work as usual when using this transport.
48+
- Use `DEBUG=openai-agents*` to inspect detailed logs during development.

docs/src/content/docs/guides/troubleshooting.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ The OpenAI Agents SDK is supported on the following server environments:
1717
- The SDK current requires `nodejs_compat` to be enabled
1818
- Traces need to be manually flushed at the end of the request. [See the tracing guide](/openai-agents-js/guides/tracing#export-loop-lifecycle) for more details.
1919
- Due to Cloudflare Workers' limited support for `AsyncLocalStorage` some traces might not be accurate
20+
- Outbound WebSocket connections must use a fetch-based upgrade (not the global `WebSocket` constructor). For Realtime, use the Cloudflare transport in `@openai/agents-extensions` (`CloudflareRealtimeTransportLayer`).
2021
- **Browsers**:
2122
- Tracing is currently not supported in browsers
2223
- **v8 isolates**:

docs/src/content/docs/guides/voice-agents/transport.mdx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import customWebRTCTransportExample from '../../../../../../examples/docs/voice-
2424
import websocketSessionExample from '../../../../../../examples/docs/voice-agents/websocketSession.ts?raw';
2525
import transportEventsExample from '../../../../../../examples/docs/voice-agents/transportEvents.ts?raw';
2626
import thinClientExample from '../../../../../../examples/docs/voice-agents/thinClient.ts?raw';
27+
import cloudflareTransportExample from '../../../../../../examples/docs/extensions/cloudflare-basic.ts?raw';
2728

2829
## Default transport layers
2930

@@ -46,6 +47,12 @@ building a phone agent with Twilio.
4647

4748
Use any recording/playback library to handle the raw PCM16 audio bytes.
4849

50+
#### Cloudflare Workers (workerd) note
51+
52+
Cloudflare Workers and other workerd runtimes cannot open outbound WebSockets using the global `WebSocket` constructor. Use the Cloudflare transport from the extensions package, which performs the `fetch()`-based upgrade internally.
53+
54+
<Code lang="typescript" code={cloudflareTransportExample} />
55+
4956
### Building your own transport mechanism
5057

5158
If you want to use a different speech-to-speech API or have your own custom transport mechanism, you
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { CloudflareRealtimeTransportLayer } from '@openai/agents-extensions';
2+
import { RealtimeAgent, RealtimeSession } from '@openai/agents/realtime';
3+
4+
const agent = new RealtimeAgent({
5+
name: 'My Agent',
6+
});
7+
8+
// Create a transport that connects to OpenAI Realtime via Cloudflare/workerd's fetch-based upgrade.
9+
const cfTransport = new CloudflareRealtimeTransportLayer({
10+
url: 'wss://api.openai.com/v1/realtime?model=gpt-4o-realtime',
11+
});
12+
13+
const session = new RealtimeSession(agent, {
14+
// Set your own transport.
15+
transport: cfTransport,
16+
});
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import {
2+
RealtimeTransportLayer,
3+
OpenAIRealtimeWebSocket,
4+
OpenAIRealtimeWebSocketOptions,
5+
} from '@openai/agents/realtime';
6+
7+
/**
8+
* An adapter transport for Cloudflare Workers (workerd) environments.
9+
*
10+
* Cloudflare Workers cannot open outbound client WebSockets using the global `WebSocket`
11+
* constructor. Instead, a `fetch()` request with `Upgrade: websocket` must be performed and the
12+
* returned `response.webSocket` must be `accept()`ed. This transport encapsulates that pattern and
13+
* plugs into the Realtime SDK via the factory-based `createWebSocket` option.
14+
*
15+
* It behaves like `OpenAIRealtimeWebSocket`, but establishes the connection using `fetch()` and
16+
* sets `skipOpenEventListeners: true` since workerd sockets do not emit a traditional `open`
17+
* event after acceptance.
18+
*
19+
* Reference: Response API — `response.webSocket` (Cloudflare Workers).
20+
* https://developers.cloudflare.com/workers/runtime-apis/response/.
21+
*/
22+
export class CloudflareRealtimeTransportLayer
23+
extends OpenAIRealtimeWebSocket
24+
implements RealtimeTransportLayer
25+
{
26+
protected _audioLengthMs: number = 0;
27+
28+
constructor(options: OpenAIRealtimeWebSocketOptions) {
29+
super({
30+
...options,
31+
createWebSocket: async ({ url, apiKey }) => {
32+
return await this.#buildCloudflareWebSocket({ url, apiKey });
33+
},
34+
skipOpenEventListeners: true,
35+
});
36+
}
37+
38+
/**
39+
* Builds a WebSocket using Cloudflare's `fetch()` + `Upgrade: websocket` flow and accepts it.
40+
* Transforms `ws(s)` to `http(s)` for the upgrade request and forwards standard headers.
41+
*/
42+
async #buildCloudflareWebSocket({
43+
url,
44+
apiKey,
45+
}: {
46+
url: string;
47+
apiKey: string;
48+
}): Promise<WebSocket> {
49+
const transformedUrl = url.replace(/^ws/i, 'http');
50+
if (!transformedUrl) {
51+
throw new Error('Realtime URL is not defined');
52+
}
53+
54+
const response = await fetch(transformedUrl, {
55+
method: 'GET',
56+
headers: {
57+
Authorization: `Bearer ${apiKey}`,
58+
'Sec-WebSocket-Protocol': 'realtime',
59+
Connection: 'Upgrade',
60+
Upgrade: 'websocket',
61+
...this.getCommonRequestHeaders(),
62+
},
63+
});
64+
65+
const upgradedSocket = (response as any).webSocket;
66+
if (!upgradedSocket) {
67+
const body = await response.text().catch(() => '');
68+
throw new Error(
69+
`Failed to upgrade websocket: ${response.status} ${body}`,
70+
);
71+
}
72+
73+
upgradedSocket.accept();
74+
return upgradedSocket as unknown as WebSocket;
75+
}
76+
}
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1-
export * from './TwilioRealtimeTransport';
21
export * from './aiSdk';
2+
export * from './CloudflareRealtimeTransport';
3+
export * from './TwilioRealtimeTransport';
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2+
import { CloudflareRealtimeTransportLayer } from '../src/CloudflareRealtimeTransport';
3+
4+
class FakeWorkersWebSocket {
5+
url: string;
6+
listeners: Record<string, ((ev: any) => void)[]> = {};
7+
accepted = false;
8+
constructor(url: string) {
9+
this.url = url;
10+
}
11+
addEventListener(type: string, listener: (ev: any) => void) {
12+
this.listeners[type] = this.listeners[type] || [];
13+
this.listeners[type].push(listener);
14+
}
15+
accept() {
16+
this.accepted = true;
17+
}
18+
send(_data: any) {}
19+
close() {
20+
this.emit('close', {});
21+
}
22+
emit(type: string, ev: any) {
23+
(this.listeners[type] || []).forEach((fn) => fn(ev));
24+
}
25+
}
26+
27+
describe('CloudflareRealtimeTransportLayer', () => {
28+
let savedFetch: any;
29+
30+
beforeEach(() => {
31+
savedFetch = (globalThis as any).fetch;
32+
});
33+
34+
afterEach(() => {
35+
(globalThis as any).fetch = savedFetch;
36+
});
37+
38+
it('connects via fetch upgrade and emits connection changes', async () => {
39+
const fakeSocket = new FakeWorkersWebSocket('ws://example');
40+
const fetchSpy = vi.fn().mockResolvedValue({
41+
webSocket: fakeSocket,
42+
status: 101,
43+
text: vi.fn().mockResolvedValue(''),
44+
});
45+
(globalThis as any).fetch = fetchSpy;
46+
47+
const transport = new CloudflareRealtimeTransportLayer({
48+
url: 'wss://api.openai.com/v1/realtime?model=foo',
49+
});
50+
51+
const statuses: string[] = [];
52+
transport.on('connection_change', (s) => statuses.push(s));
53+
54+
await transport.connect({ apiKey: 'ek_test', model: 'foo' });
55+
56+
expect(fetchSpy).toHaveBeenCalledTimes(1);
57+
// wss -> https
58+
expect(fetchSpy.mock.calls[0][0]).toBe(
59+
'https://api.openai.com/v1/realtime?model=foo',
60+
);
61+
const init = fetchSpy.mock.calls[0][1];
62+
expect(init.method).toBe('GET');
63+
expect(init.headers['Authorization']).toBe('Bearer ek_test');
64+
expect(init.headers['Upgrade']).toBe('websocket');
65+
expect(init.headers['Connection']).toBe('Upgrade');
66+
expect(init.headers['Sec-WebSocket-Protocol']).toBe('realtime');
67+
68+
// connected without relying on 'open' listener.
69+
expect(statuses).toEqual(['connecting', 'connected']);
70+
});
71+
72+
it('propagates fetch-upgrade failures with detailed error', async () => {
73+
const fetchSpy = vi.fn().mockResolvedValue({
74+
status: 400,
75+
text: vi.fn().mockResolvedValue('No upgrade'),
76+
});
77+
(globalThis as any).fetch = fetchSpy;
78+
79+
const transport = new CloudflareRealtimeTransportLayer({
80+
url: 'wss://api.openai.com/v1/realtime?model=bar',
81+
});
82+
83+
await expect(
84+
transport.connect({ apiKey: 'ek_x', model: 'bar' }),
85+
).rejects.toThrow('Failed to upgrade websocket: 400 No upgrade');
86+
});
87+
});

0 commit comments

Comments
 (0)