Skip to content

Commit d84e88b

Browse files
authored
feat: Make openai_realtime_dart client to strong-typed (#590)
1 parent d516b14 commit d84e88b

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

60 files changed

+93947
-843
lines changed

packages/openai_dart/oas/main.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import 'dart:io';
33

44
import 'package:openapi_spec/openapi_spec.dart';
55

6-
/// Generates Chroma API client Dart code from the OpenAPI spec.
6+
/// Generates OpenAI API client Dart code from the OpenAPI spec.
77
/// Official spec: https://github.com/openai/openai-openapi/blob/master/openapi.yaml
88
void main() async {
99
final spec = OpenApi.fromFile(source: 'oas/openapi_curated.yaml');

packages/openai_realtime_dart/README.md

Lines changed: 90 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -36,28 +36,33 @@ final client = RealtimeClient(
3636
);
3737
3838
// Can set parameters ahead of connecting, either separately or all at once
39-
client.updateSession(instructions: 'You are a great, upbeat friend.');
40-
client.updateSession(voice: 'alloy');
41-
client.updateSession(
42-
turnDetection: {'type': 'none'},
43-
inputAudioTranscription: {'model': 'whisper-1'},
39+
await client.updateSession(instructions: 'You are a great, upbeat friend.');
40+
await client.updateSession(voice: Voice.alloy);
41+
await client.updateSession(
42+
turnDetection: TurnDetection(
43+
type: TurnDetectionType.serverVad,
44+
),
45+
inputAudioTranscription: InputAudioTranscriptionConfig(
46+
model: 'whisper-1',
47+
),
4448
);
4549
4650
// Set up event handling
47-
client.on('conversation.updated', (event) {
51+
client.on(RealtimeEventType.conversationUpdated, (event) {
4852
// item is the current item being updated
49-
final item = event?['item'];
50-
// delta can be null or populated
51-
final delta = event?['delta'];
53+
final result = (event as RealtimeEventConversationUpdated).result;
54+
final item = result.item;
55+
final delta = result.delta;
5256
// you can fetch a full list of items at any time
57+
final items = client.conversation.getItems();
5358
});
5459
5560
// Connect to Realtime API
5661
await client.connect();
5762
5863
// Send a item and triggers a generation
59-
client.sendUserMessageContent([
60-
{'type': 'input_text', 'text': 'How are you?'},
64+
await client.sendUserMessageContent([
65+
const ContentPart.inputText(text: 'How are you?'),
6166
]);
6267
```
6368

@@ -94,7 +99,7 @@ In this package, there are three primitives for interfacing with the Realtime AP
9499
- Thin wrapper over [WebSocket](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket)
95100
- Use this for connecting to the API, authenticating, and sending items
96101
- There is **no item validation**, you will have to rely on the API specification directly
97-
- Dispatches events as `server.{event_name}` and `client.{event_name}`, respectively
102+
- Dispatches events according to the `RealtimeEventType` enum
98103
3. [`RealtimeConversation`](./lib/src/conversation.dart)
99104
- Exists on client instance as `client.conversation`
100105
- Stores a client-side cache of the current conversation
@@ -109,18 +114,18 @@ The client comes packaged with some basic utilities that make it easy to build r
109114
Sending messages to the server from the user is easy.
110115

111116
```dart
112-
client.sendUserMessageContent([
113-
{'type': 'input_text', 'text': 'How are you?'},
117+
await client.sendUserMessageContent([
118+
const ContentPart.inputText(text: 'How are you?'),
114119
]);
115120
// or (empty audio)
116-
client.sendUserMessageContent([
117-
{'type': 'input_audio', 'audio': Uint8List(0)},
121+
await client.sendUserMessageContent([
122+
ContentPart.inputAudio(audio: ''), // Base64 encoded audio
118123
]);
119124
```
120125

121126
### Sending streaming audio
122127

123-
To send streaming audio, use the `.appendInputAudio()` method. If you're in `turn_detection: 'disabled'` mode, then you need to use `.createResponse()` to tell the model to respond.
128+
To send streaming audio, use the `.appendInputAudio()` method. If you're in manual mode (no turn detection), then you need to use `.createResponse()` to tell the model to respond.
124129

125130
```dart
126131
// Send user audio, must be Uint8List
@@ -132,53 +137,48 @@ for (var i = 0; i < 10; i++) {
132137
final value = (Random().nextDouble() * 2 - 1) * 0x8000;
133138
data[n] = value.toInt();
134139
}
135-
client.appendInputAudio(data);
140+
await client.appendInputAudio(data);
136141
}
137142
// Pending audio is committed and model is asked to generate
138-
client.createResponse();
143+
await client.createResponse();
139144
```
140145

141146
### Adding and using tools
142147

143148
Working with tools is easy. Just call `.addTool()` and set a callback as the second parameter. The callback will be executed with the parameters for the tool, and the result will be automatically sent back to the model.
144149

145150
```dart
146-
// We can add tools as well, with callbacks specified
147-
client.addTool(
148-
{
149-
'name': 'get_weather',
150-
'description': 'Retrieves the weather for a given lat, lng coordinate pair. Specify a label for the location.',
151-
'parameters': {
151+
await client.addTool(
152+
const ToolDefinition(
153+
name: 'get_weather',
154+
description: 'Retrieves the weather for a location given its latitude and longitude coordinate pair.',
155+
parameters: {
152156
'type': 'object',
153157
'properties': {
154158
'lat': {
155159
'type': 'number',
156-
'description': 'Latitude',
160+
'description': 'Latitude of the location',
157161
},
158162
'lng': {
159163
'type': 'number',
160-
'description': 'Longitude',
161-
},
162-
'location': {
163-
'type': 'string',
164-
'description': 'Name of the location',
164+
'description': 'Longitude of the location',
165165
},
166166
},
167-
'required': ['lat', 'lng', 'location'],
167+
'required': ['lat', 'lng'],
168168
},
169-
},
169+
),
170170
(Map<String, dynamic> params) async {
171171
final result = await HttpClient()
172172
.getUrl(
173173
Uri.parse(
174174
'https://api.open-meteo.com/v1/forecast?'
175-
'latitude=${params['lat']}&'
176-
'longitude=${params['lng']}&'
177-
'current=temperature_2m,wind_speed_10m',
175+
'latitude=${params['lat']}&'
176+
'longitude=${params['lng']}&'
177+
'current=temperature_2m,wind_speed_10m',
178178
),
179179
)
180180
.then((request) => request.close())
181-
.then((response) => response.transform(Utf8Decoder()).join())
181+
.then((res) => res.transform(const Utf8Decoder()).join())
182182
.then(jsonDecode);
183183
return result;
184184
},
@@ -189,19 +189,17 @@ client.addTool(
189189

190190
The `.addTool()` method automatically runs a tool handler and triggers a response on handler completion. Sometimes you may not want that, for example: using tools to generate a schema that you use for other purposes.
191191

192-
In this case, we can use the `tools` item with `updateSession`. In this case you **must** specify `type: 'function'`, which is not required for `.addTool()`.
192+
In this case, we can use the `tools` parameter with `updateSession`.
193193

194194
**Note:** Tools added with `.addTool()` will **not** be overridden when updating sessions manually like this, but every `updateSession()` change will override previous `updateSession()` changes. Tools added via `.addTool()` are persisted and appended to anything set manually here.
195195

196196
```dart
197-
client.updateSession(
197+
await client.updateSession(
198198
tools: [
199-
{
200-
'type': 'function',
201-
'name': 'get_weather',
202-
'description':
203-
'Retrieves the weather for a given lat, lng coordinate pair. Specify a label for the location.',
204-
'parameters': {
199+
const ToolDefinition(
200+
name: 'get_weather',
201+
description: 'Retrieves the weather for a location given its latitude and longitude coordinate pair.',
202+
parameters: {
205203
'type': 'object',
206204
'properties': {
207205
'lat': {
@@ -212,124 +210,118 @@ client.updateSession(
212210
'type': 'number',
213211
'description': 'Longitude',
214212
},
215-
'location': {
216-
'type': 'string',
217-
'description': 'Name of the location',
218-
},
219213
},
220-
'required': ['lat', 'lng', 'location'],
214+
'required': ['lat', 'lng'],
221215
},
222-
},
216+
),
223217
],
224218
);
225219
```
226220

227221
Then, to handle function calls...
228222

229223
```dart
230-
client.on('conversation.item.completed', (event) {
231-
final item = event?['item'] as Map<String, dynamic>?;
232-
if (item?['type'] == 'function_call') {
224+
client.on(RealtimeEventType.conversationItemCompleted, (event) {
225+
final item = (event as RealtimeEventConversationItemCompleted).item;
226+
if (item.item is ItemFunctionCall) {
233227
// your function call is complete, execute some custom code
234228
}
235229
});
236230
```
237231

238232
### Interrupting the model
239233

240-
You may want to manually interrupt the model, especially in `turn_detection: 'disabled'` mode. To do this, we can use:
234+
You may want to manually interrupt the model, especially when not using turn detection. To do this, we can use:
241235

242236
```dart
243237
// id is the id of the item currently being generated
244238
// sampleCount is the number of audio samples that have been heard by the listener
245-
client.cancelResponse(id, sampleCount);
239+
await client.cancelResponse(id, sampleCount);
246240
```
247241

248242
This method will cause the model to immediately cease generation, but also truncate the item being played by removing all audio after `sampleCount` and clearing the text response. By using this method you can interrupt the model and prevent it from "remembering" anything it has generated that is ahead of where the user's state is.
249243

250244
## Client events
251245

252-
If you need more manual control and want to send custom client events according to the [Realtime Client Events API Reference](https://platform.openai.com/docs/api-reference/realtime-client-events), you can use `client.realtime.send()` like so:
246+
The `RealtimeClient` provides strongly typed events that map to the [Realtime API Events](https://platform.openai.com/docs/api-reference/realtime-events). You can listen to specific events using the `RealtimeEventType` enum.
253247

254248
```dart
255-
client.realtime.send('conversation.item.create', {
256-
'item': {
257-
'type': 'function_call_output',
258-
'call_id': 'my-call-id',
259-
'output': '{function_succeeded:true}',
260-
},
261-
});
249+
client.realtime.send(
250+
RealtimeEvent.conversationItemCreate(
251+
eventId: RealtimeUtils.generateId(),
252+
item: Item.functionCallOutput(
253+
id: RealtimeUtils.generateId(),
254+
callId: 'my-call-id',
255+
output: '{function_succeeded:true}',
256+
),
257+
),
258+
);
262259
```
263260

264261
### Utility events
265262

266-
With `RealtimeClient` we have reduced the event overhead from server events to **five** main events that are most critical for your application control flow. These events **are not** part of the API specification itself, but wrap logic to make application development easier.
263+
With `RealtimeClient` we have reduced the event overhead from server events to **five** main events that are most critical for your application control flow:
267264

268265
```dart
269266
// Errors like connection failures
270-
client.on('error', (event) {
271-
// do something
267+
client.on(RealtimeEventType.error, (event) {
268+
final error = (event as RealtimeEventError).error;
269+
// do something with the error
272270
});
273271
274272
// In VAD mode, the user starts speaking
275273
// we can use this to stop audio playback of a previous response if necessary
276-
client.on('conversation.interrupted', (event) {
277-
// do something
274+
client.on(RealtimeEventType.conversationInterrupted, (event) {
275+
// handle interruption
278276
});
279277
280278
// Includes all changes to conversations
281-
// delta may be populated
282-
client.on('conversation.updated', (event) {
283-
final item = event?['item'] as Map<String, dynamic>?;
284-
final delta = event?['delta'] as Map<String, dynamic>?;
279+
client.on(RealtimeEventType.conversationUpdated, (event) {
280+
final result = (event as RealtimeEventConversationUpdated).result;
281+
final item = result.item;
282+
final delta = result.delta;
285283
286284
// get all items, e.g. if you need to update a chat window
287285
final items = client.conversation.getItems();
288286
289-
final type = item?['type'] as String?;
290-
switch (type) {
291-
case 'message':
292-
// system, user, or assistant message (item.role)
293-
case 'function_call':
294-
// always a function call from the model
295-
case 'function_call_output':
296-
// always a response from the user / application
287+
if (item?.item case final ItemMessage message) {
288+
// system, user, or assistant message (message.role)
289+
} else if (item?.item case final ItemFunctionCall functionCall) {
290+
// always a function call from the model
291+
} else if (item?.item case final ItemFunctionCallOutput functionCallOutput) {
292+
// always a response from the user / application
297293
}
294+
298295
if (delta != null) {
299296
// Only one of the following will be populated for any given event
300-
// delta['audio'] -> Uint8List, audio added
301-
// delta['transcript'] -> string, transcript added
302-
// delta['arguments'] -> string, function arguments added
297+
// delta.audio -> Uint8List, audio added
298+
// delta.transcript -> string, transcript added
299+
// delta.arguments -> string, function arguments added
303300
}
304301
});
305302
306303
// Only triggered after item added to conversation
307-
client.on('conversation.item.appended', (event) {
308-
final item = event?['item'] as Map<String, dynamic>?;
309-
// item?['status'] -> can be 'in_progress' or 'completed'
304+
client.on(RealtimeEventType.conversationItemAppended, (event) {
305+
final item = (event as RealtimeEventConversationItemAppended).item;
306+
// item.status can be ItemStatus.inProgress or ItemStatus.completed
310307
});
311308
312309
// Only triggered after item completed in conversation
313310
// will always be triggered after conversation.item.appended
314-
client.on('conversation.item.completed', (event) {
315-
final item = event?['item'] as Map<String, dynamic>?;
316-
// item?['status'] -> will always be 'completed'
311+
client.on(RealtimeEventType.conversationItemCompleted, (event) {
312+
final item = (event as RealtimeEventConversationItemCompleted).item;
313+
// item.status will always be ItemStatus.completed
317314
});
318315
```
319316

320317
### Server events
321318

322-
If you want more control over your application development, you can use the `realtime.event` event and choose only to respond to **server** events. The full documentation for these events are available on the [Realtime Server Events API Reference](https://platform.openai.com/docs/api-reference/realtime-server-events).
319+
If you want more control over your application development, you can use the `RealtimeEventType.all` event and choose only to respond to **server** events. The full documentation for these events are available on the [Realtime Server Events API Reference](https://platform.openai.com/docs/api-reference/realtime-server-events).
323320

324321
```dart
325322
// all events, can use for logging, debugging, or manual event handling
326-
client.on('realtime.event', (event ) {
327-
final time = event?['time'] as String?;
328-
final source = event?['source'] as String?;
329-
final eventPayload = event?['event'] as Map<String, dynamic>?;
330-
if (source == 'server') {
331-
// do something
332-
}
323+
client.on(RealtimeEventType.all, (event) {
324+
// Handle any RealtimeEvent
333325
});
334326
```
335327

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
targets:
2+
$default:
3+
builders:
4+
source_gen|combining_builder:
5+
options:
6+
ignore_for_file:
7+
- prefer_final_parameters
8+
- require_trailing_commas
9+
- non_constant_identifier_names
10+
- unnecessary_null_checks
11+
json_serializable:
12+
options:
13+
explicit_to_json: true

0 commit comments

Comments
 (0)