Skip to content

Commit b9fc11f

Browse files
authored
feat: add a method to connect OpenAI agent to call (#82)
1 parent 9239648 commit b9fc11f

File tree

10 files changed

+259
-3
lines changed

10 files changed

+259
-3
lines changed

.env-example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
STREAM_API_KEY=<API key>
22
STREAM_SECRET=<SECRET>
3+
OPENAI_API_KEY=<OpenAI API key>

.github/workflows/test.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ env:
33
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
44
STREAM_API_KEY: ${{ vars.TEST_API_KEY }}
55
STREAM_SECRET: ${{ secrets.TEST_SECRET }}
6+
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
67

78
on:
89
push:

__tests__/agent.test.ts

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { v4 as uuidv4 } from 'uuid';
2+
import { vi, describe, expect, it } from 'vitest';
3+
import { createTestClient } from './create-test-client.js';
4+
import { StreamClient } from '../src/StreamClient.js';
5+
6+
const openAiApiKey = process.env.OPENAI_API_KEY!;
7+
const enableDebugLogging = false;
8+
9+
async function createTestStreamAndRealtimeClients() {
10+
const streamClient = createTestClient();
11+
const call = streamClient.video.call('default', `call${uuidv4()}`);
12+
13+
const realtimeClient = await streamClient.video.connectOpenAi({
14+
call,
15+
openAiApiKey,
16+
agentUserId: 'my-ai-friend',
17+
validityInSeconds: 60 * 60,
18+
});
19+
20+
if (enableDebugLogging) {
21+
realtimeClient.on('conversation.updated', console.debug);
22+
realtimeClient.on('realtime.event', console.debug);
23+
}
24+
25+
return [streamClient, realtimeClient] as const;
26+
}
27+
28+
describe.skip('AI agent integration', () => {
29+
it('connects', async () => {
30+
try {
31+
await createTestStreamAndRealtimeClients();
32+
} catch (e) {
33+
console.log(e);
34+
expect.unreachable();
35+
}
36+
});
37+
38+
it('should throw on invalid OpenAI credentials', async () => {
39+
const streamClient = createTestClient();
40+
const call = streamClient.video.call('default', `call${uuidv4()}`);
41+
42+
await expect(
43+
streamClient.video.connectOpenAi({
44+
call,
45+
openAiApiKey: 'wrong-key',
46+
agentUserId: 'my-ai-friend',
47+
validityInSeconds: 60 * 60,
48+
}),
49+
).rejects.toThrowError();
50+
});
51+
52+
it('should throw on invalid Stream credentials', async () => {
53+
const streamClient = new StreamClient('', 'secret');
54+
const call = streamClient.video.call('default', `call${uuidv4()}`);
55+
56+
await expect(
57+
streamClient.video.connectOpenAi({
58+
call,
59+
openAiApiKey,
60+
agentUserId: 'my-ai-friend',
61+
validityInSeconds: 60 * 60,
62+
}),
63+
).rejects.toThrowError();
64+
});
65+
66+
it('should dispatch event with type error', async () => {
67+
const [, agent] = await createTestStreamAndRealtimeClients();
68+
let errorEvent: any = null;
69+
70+
agent.on('realtime.event', ({ event }) => {
71+
if (event.type === 'error') {
72+
errorEvent = event;
73+
}
74+
});
75+
76+
agent.updateSession({ voice: 'does-not-exists' as any });
77+
78+
await vi.waitUntil(() => errorEvent !== null);
79+
expect(errorEvent.type).to.eq('error');
80+
expect(errorEvent.error.param).to.eq('session.voice');
81+
});
82+
83+
it('updates session', async () => {
84+
try {
85+
const [, agent] = await createTestStreamAndRealtimeClients();
86+
87+
agent.updateSession({
88+
instructions:
89+
'If someone is called Tommaso please be extremely rude and mean to them.',
90+
voice: 'alloy',
91+
turn_detection: null, // or 'server_vad'
92+
input_audio_transcription: { model: 'whisper-1' },
93+
});
94+
} catch {
95+
expect.unreachable();
96+
}
97+
});
98+
99+
it('sends a message', async () => {
100+
try {
101+
const [, agent] = await createTestStreamAndRealtimeClients();
102+
103+
agent.updateSession({
104+
instructions:
105+
'If someone is called Tommaso please be extremely rude and mean to them.',
106+
voice: 'alloy',
107+
turn_detection: null, // or 'server_vad'
108+
input_audio_transcription: { model: 'whisper-1' },
109+
});
110+
111+
agent.sendUserMessageContent([
112+
{
113+
type: 'input_text',
114+
text: 'Hi, my name is Tommaso, how is your day?',
115+
},
116+
]);
117+
} catch {
118+
expect.unreachable();
119+
}
120+
});
121+
122+
it('adds a tool', async () => {
123+
try {
124+
const [, agent] = await createTestStreamAndRealtimeClients();
125+
126+
agent.addTool(
127+
{
128+
name: 'get_weather',
129+
description:
130+
'Retrieves the weather for a given lat, lng coordinate pair. Specify a label for the location.',
131+
parameters: {
132+
type: 'object',
133+
properties: {
134+
lat: {
135+
type: 'number',
136+
description: 'Latitude',
137+
},
138+
lng: {
139+
type: 'number',
140+
description: 'Longitude',
141+
},
142+
location: {
143+
type: 'string',
144+
description: 'Name of the location',
145+
},
146+
},
147+
required: ['lat', 'lng', 'location'],
148+
},
149+
},
150+
async ({ lat, lng }) => {
151+
const result = await fetch(
152+
`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}&current=temperature_2m,wind_speed_10m`,
153+
);
154+
const json = await result.json();
155+
return json;
156+
},
157+
);
158+
159+
agent.sendUserMessageContent([
160+
{
161+
type: 'input_text',
162+
text: `How is the weather in Boulder colorado?`,
163+
},
164+
]);
165+
} catch {
166+
expect.unreachable();
167+
}
168+
});
169+
});

package.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
"@openapitools/openapi-generator-cli": "^2.7.0",
5050
"@rollup/plugin-replace": "^5.0.2",
5151
"@rollup/plugin-typescript": "^11.1.4",
52+
"@stream-io/openai-realtime-api": "prerelease",
5253
"@types/uuid": "^9.0.4",
5354
"@typescript-eslint/eslint-plugin": "^6.4.0",
5455
"dotenv": "^16.3.1",
@@ -74,6 +75,14 @@
7475
"jsonwebtoken": "^9.0.2",
7576
"uuid": "^9.0.1"
7677
},
78+
"peerDependencies": {
79+
"@stream-io/openai-realtime-api": "prerelease"
80+
},
81+
"peerDependenciesMeta": {
82+
"@stream-io/openai-realtime-api": {
83+
"optional": true
84+
}
85+
},
7786
"engines": {
7887
"node": ">=18.0.0"
7988
},

rollup.config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const nodeConfig = {
1111
{
1212
file: "dist/index.cjs.js",
1313
format: "cjs",
14+
dynamicImportInCjs: false,
1415
sourcemap: true,
1516
},
1617
{

src/BaseApi.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { APIError } from './gen/models';
44
import { getRateLimitFromResponseHeader } from './utils/rate-limit';
55

66
export class BaseApi {
7-
constructor(private readonly apiConfig: ApiConfig) {}
7+
constructor(protected readonly apiConfig: ApiConfig) {}
88

99
protected sendRequest = async <T>(
1010
method: string,

src/StreamClient.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export class StreamClient extends CommonApi {
3838
super({ apiKey, token, timeout, baseUrl: chatBaseUrl });
3939

4040
this.video = new StreamVideoClient({
41+
streamClient: this,
4142
apiKey,
4243
token,
4344
timeout,

src/StreamVideoClient.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,63 @@
11
import { VideoApi } from './gen/video/VideoApi';
22
import { StreamCall } from './StreamCall';
3+
import type { StreamClient } from './StreamClient';
4+
import type { ApiConfig } from './types';
5+
import type {
6+
RealtimeClient,
7+
createRealtimeClient,
8+
} from '@stream-io/openai-realtime-api';
39

410
export class StreamVideoClient extends VideoApi {
11+
private readonly streamClient: StreamClient;
12+
13+
constructor({
14+
streamClient,
15+
...apiConfig
16+
}: ApiConfig & { streamClient: StreamClient }) {
17+
super(apiConfig);
18+
this.streamClient = streamClient;
19+
}
20+
521
call = (type: string, id: string) => {
622
return new StreamCall(this, type, id);
723
};
24+
25+
connectOpenAi = async (options: {
26+
call: StreamCall;
27+
agentUserId: string;
28+
openAiApiKey: string;
29+
validityInSeconds: number;
30+
}): Promise<RealtimeClient> => {
31+
let doCreateRealtimeClient: typeof createRealtimeClient;
32+
33+
try {
34+
doCreateRealtimeClient = (await import('@stream-io/openai-realtime-api'))
35+
.createRealtimeClient;
36+
} catch {
37+
throw new Error(
38+
'Cannot create Realtime API client. Is @stream-io/openai-realtime-api installed?',
39+
);
40+
}
41+
42+
if (!options.agentUserId) {
43+
throw new Error('"agentUserId" must by specified in options');
44+
}
45+
46+
const token = this.streamClient.generateCallToken({
47+
user_id: options.agentUserId,
48+
call_cids: [options.call.cid],
49+
validity_in_seconds: options.validityInSeconds,
50+
});
51+
52+
const realtimeClient = doCreateRealtimeClient({
53+
baseUrl: this.apiConfig.baseUrl,
54+
call: options.call,
55+
streamApiKey: this.apiConfig.apiKey,
56+
streamUserToken: token,
57+
openAiApiKey: options.openAiApiKey,
58+
});
59+
60+
await realtimeClient.connect();
61+
return realtimeClient;
62+
};
863
}

tsconfig.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
{
22
"compilerOptions": {
33
"outDir": "./dist",
4-
"module": "ES2015",
5-
"target": "ES2015",
4+
"module": "ES2020",
5+
"target": "ES2020",
66
"lib": ["esnext", "dom"],
77
"noEmitOnError": true,
88
"noImplicitAny": true,

yarn.lock

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,12 @@
365365
consola "^2.15.0"
366366
node-fetch "^2.6.1"
367367

368+
"@openai/realtime-api-beta@openai/openai-realtime-api-beta#a5cb94824f625423858ebacb9f769226ca98945f":
369+
version "0.0.0"
370+
resolved "https://codeload.github.com/openai/openai-realtime-api-beta/tar.gz/a5cb94824f625423858ebacb9f769226ca98945f"
371+
dependencies:
372+
ws "^8.18.0"
373+
368374
"@openapitools/openapi-generator-cli@^2.7.0":
369375
version "2.7.0"
370376
resolved "https://registry.npmjs.org/@openapitools/openapi-generator-cli/-/openapi-generator-cli-2.7.0.tgz"
@@ -482,6 +488,14 @@
482488
resolved "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz"
483489
integrity sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==
484490

491+
"@stream-io/openai-realtime-api@prerelease":
492+
version "0.0.0-24dd081d4a88212c621cfee273690bebd4a5f298"
493+
resolved "https://registry.yarnpkg.com/@stream-io/openai-realtime-api/-/openai-realtime-api-0.0.0-24dd081d4a88212c621cfee273690bebd4a5f298.tgz#bb8aac285342f7390cead6414b3ebf7cdc1048b3"
494+
integrity sha512-1CKZnKaXumPZ4lrzVIam8qE27UVyEFTs4wbir0opZYE8+e4whtkx8hfgiwbn/Y2yStO6yZpCjwtWVKyi2jd65Q==
495+
dependencies:
496+
"@openai/realtime-api-beta" openai/openai-realtime-api-beta#a5cb94824f625423858ebacb9f769226ca98945f
497+
ws "^8.18.0"
498+
485499
"@types/[email protected]", "@types/estree@^1.0.0":
486500
version "1.0.5"
487501
resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz"
@@ -3470,6 +3484,11 @@ wrappy@1:
34703484
resolved "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz"
34713485
integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==
34723486

3487+
ws@^8.18.0:
3488+
version "8.18.0"
3489+
resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.0.tgz#0d7505a6eafe2b0e712d232b42279f53bc289bbc"
3490+
integrity sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==
3491+
34733492
y18n@^5.0.5:
34743493
version "5.0.8"
34753494
resolved "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz"

0 commit comments

Comments
 (0)