208 lines
7.4 KiB
JavaScript
208 lines
7.4 KiB
JavaScript
import { RealtimeEventHandler } from './event_handler.js';
|
|
import { RealtimeUtils } from './utils.js';
|
|
|
|
export class RealtimeAPI extends RealtimeEventHandler {
|
|
/**
|
|
* Create a new RealtimeAPI instance
|
|
* @param {{url?: string, apiKey?: string, dangerouslyAllowAPIKeyInBrowser?: boolean, debug?: boolean}} [settings]
|
|
* @returns {RealtimeAPI}
|
|
*/
|
|
constructor({ url, apiKey, dangerouslyAllowAPIKeyInBrowser, debug } = {}) {
|
|
super();
|
|
this.defaultUrl = 'wss://api.openai.com/v1/realtime';
|
|
this.url = url || this.defaultUrl;
|
|
this.apiKey = apiKey || null;
|
|
this.debug = !!debug;
|
|
this.ws = null;
|
|
if (globalThis.document && this.apiKey) {
|
|
if (!dangerouslyAllowAPIKeyInBrowser) {
|
|
throw new Error(
|
|
`Can not provide API key in the browser without "dangerouslyAllowAPIKeyInBrowser" set to true`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Tells us whether or not the WebSocket is connected
|
|
* @returns {boolean}
|
|
*/
|
|
isConnected() {
|
|
return !!this.ws;
|
|
}
|
|
|
|
/**
|
|
* Writes WebSocket logs to console
|
|
* @param {...any} args
|
|
* @returns {true}
|
|
*/
|
|
log(...args) {
|
|
const date = new Date().toISOString();
|
|
const logs = [`[Websocket/${date}]`].concat(args).map((arg) => {
|
|
if (typeof arg === 'object' && arg !== null) {
|
|
return JSON.stringify(arg, null, 2);
|
|
} else {
|
|
return arg;
|
|
}
|
|
});
|
|
if (this.debug) {
|
|
console.log(...logs);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Connects to Realtime API Websocket Server
|
|
* @param {{model?: string}} [settings]
|
|
* @returns {Promise<true>}
|
|
*/
|
|
async connect({ model } = { model: 'gpt-4o-realtime-preview-2024-10-01' }) {
|
|
if (!this.apiKey && this.url === this.defaultUrl) {
|
|
console.warn(`No apiKey provided for connection to "${this.url}"`);
|
|
}
|
|
if (this.isConnected()) {
|
|
throw new Error(`Already connected`);
|
|
}
|
|
if (globalThis.WebSocket) {
|
|
/**
|
|
* Web browser
|
|
*/
|
|
if (globalThis.document && this.apiKey) {
|
|
console.warn(
|
|
'Warning: Connecting using API key in the browser, this is not recommended',
|
|
);
|
|
}
|
|
const WebSocket = globalThis.WebSocket;
|
|
const ws = new WebSocket(`${this.url}${model ? `?model=${model}` : ''}`, [
|
|
'realtime',
|
|
`openai-insecure-api-key.${this.apiKey}`,
|
|
'openai-beta.realtime-v1',
|
|
]);
|
|
ws.addEventListener('message', (event) => {
|
|
const message = JSON.parse(event.data);
|
|
this.receive(message.type, message);
|
|
});
|
|
return new Promise((resolve, reject) => {
|
|
const connectionErrorHandler = () => {
|
|
this.disconnect(ws);
|
|
reject(new Error(`Could not connect to "${this.url}"`));
|
|
};
|
|
ws.addEventListener('error', connectionErrorHandler);
|
|
ws.addEventListener('open', () => {
|
|
this.log(`Connected to "${this.url}"`);
|
|
ws.removeEventListener('error', connectionErrorHandler);
|
|
ws.addEventListener('error', () => {
|
|
this.disconnect(ws);
|
|
this.log(`Error, disconnected from "${this.url}"`);
|
|
this.dispatch('close', { error: true });
|
|
});
|
|
ws.addEventListener('close', () => {
|
|
this.disconnect(ws);
|
|
this.log(`Disconnected from "${this.url}"`);
|
|
this.dispatch('close', { error: false });
|
|
});
|
|
this.ws = ws;
|
|
resolve(true);
|
|
});
|
|
});
|
|
} else {
|
|
/**
|
|
* Node.js
|
|
*/
|
|
const moduleName = 'ws';
|
|
const wsModule = await import(/* webpackIgnore: true */ moduleName);
|
|
const WebSocket = wsModule.default;
|
|
const ws = new WebSocket(
|
|
'wss://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview-2024-10-01',
|
|
[],
|
|
{
|
|
finishRequest: (request) => {
|
|
// Auth
|
|
request.setHeader('Authorization', `Bearer ${this.apiKey}`);
|
|
request.setHeader('OpenAI-Beta', 'realtime=v1');
|
|
request.end();
|
|
},
|
|
},
|
|
);
|
|
ws.on('message', (data) => {
|
|
const message = JSON.parse(data.toString());
|
|
this.receive(message.type, message);
|
|
});
|
|
return new Promise((resolve, reject) => {
|
|
const connectionErrorHandler = () => {
|
|
this.disconnect(ws);
|
|
reject(new Error(`Could not connect to "${this.url}"`));
|
|
};
|
|
ws.on('error', connectionErrorHandler);
|
|
ws.on('open', () => {
|
|
this.log(`Connected to "${this.url}"`);
|
|
ws.removeListener('error', connectionErrorHandler);
|
|
ws.on('error', () => {
|
|
this.disconnect(ws);
|
|
this.log(`Error, disconnected from "${this.url}"`);
|
|
this.dispatch('close', { error: true });
|
|
});
|
|
ws.on('close', () => {
|
|
this.disconnect(ws);
|
|
this.log(`Disconnected from "${this.url}"`);
|
|
this.dispatch('close', { error: false });
|
|
});
|
|
this.ws = ws;
|
|
resolve(true);
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Disconnects from Realtime API server
|
|
* @param {WebSocket} [ws]
|
|
* @returns {true}
|
|
*/
|
|
disconnect(ws) {
|
|
if (!ws || this.ws === ws) {
|
|
this.ws && this.ws.close();
|
|
this.ws = null;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Receives an event from WebSocket and dispatches as "server.{eventName}" and "server.*" events
|
|
* @param {string} eventName
|
|
* @param {{[key: string]: any}} event
|
|
* @returns {true}
|
|
*/
|
|
receive(eventName, event) {
|
|
this.log(`received:`, eventName, event);
|
|
this.dispatch(`server.${eventName}`, event);
|
|
this.dispatch('server.*', event);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Sends an event to WebSocket and dispatches as "client.{eventName}" and "client.*" events
|
|
* @param {string} eventName
|
|
* @param {{[key: string]: any}} event
|
|
* @returns {true}
|
|
*/
|
|
send(eventName, data) {
|
|
if (!this.isConnected()) {
|
|
throw new Error(`RealtimeAPI is not connected`);
|
|
}
|
|
data = data || {};
|
|
if (typeof data !== 'object') {
|
|
throw new Error(`data must be an object`);
|
|
}
|
|
const event = {
|
|
event_id: RealtimeUtils.generateId('evt_'),
|
|
type: eventName,
|
|
...data,
|
|
};
|
|
this.dispatch(`client.${eventName}`, event);
|
|
this.dispatch('client.*', event);
|
|
this.log(`sent:`, eventName, event);
|
|
this.ws.send(JSON.stringify(event));
|
|
return true;
|
|
}
|
|
}
|