@memberjunction/ai-openai 5.47.0 → 5.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/models/openAI.d.ts +50 -1
- package/dist/models/openAI.d.ts.map +1 -1
- package/dist/models/openAI.js +107 -4
- package/dist/models/openAI.js.map +1 -1
- package/dist/models/openAIEmbedding.d.ts.map +1 -1
- package/dist/models/openAIEmbedding.js +4 -2
- package/dist/models/openAIEmbedding.js.map +1 -1
- package/dist/models/openAIRealtime.d.ts +298 -21
- package/dist/models/openAIRealtime.d.ts.map +1 -1
- package/dist/models/openAIRealtime.js +568 -72
- package/dist/models/openAIRealtime.js.map +1 -1
- package/dist/models/rawRealtimeWebSocketConnection.d.ts +86 -0
- package/dist/models/rawRealtimeWebSocketConnection.d.ts.map +1 -0
- package/dist/models/rawRealtimeWebSocketConnection.js +147 -0
- package/dist/models/rawRealtimeWebSocketConnection.js.map +1 -0
- package/package.json +3 -3
- package/readme.md +26 -0
|
@@ -8,7 +8,7 @@ var __metadata = (this && this.__metadata) || function (k, v) {
|
|
|
8
8
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
9
9
|
};
|
|
10
10
|
import { RegisterClass } from '@memberjunction/global';
|
|
11
|
-
import { BaseRealtimeModel, RealtimeDiagLog, } from '@memberjunction/ai';
|
|
11
|
+
import { BaseRealtimeModel, RealtimeDiagLog, IsTranscriptContinuation, } from '@memberjunction/ai';
|
|
12
12
|
import { OpenAI } from 'openai';
|
|
13
13
|
import { OpenAIRealtimeWebSocket } from 'openai/realtime/websocket';
|
|
14
14
|
/**
|
|
@@ -19,6 +19,213 @@ import { OpenAIRealtimeWebSocket } from 'openai/realtime/websocket';
|
|
|
19
19
|
* contract's promise of both-role transcripts holds everywhere.
|
|
20
20
|
*/
|
|
21
21
|
const OPENAI_INPUT_TRANSCRIPTION_MODEL = 'gpt-4o-mini-transcribe';
|
|
22
|
+
/** Runtime validation set for {@link RealtimeReasoningEffort} values arriving via the untyped Config bag. */
|
|
23
|
+
const REALTIME_REASONING_EFFORTS = new Set(['minimal', 'low', 'medium', 'high', 'xhigh']);
|
|
24
|
+
/**
|
|
25
|
+
* Maps MJ's NORMALIZED effort level (the same `ChatParams.effortLevel` vocabulary the LLM drivers
|
|
26
|
+
* consume: a numeric 1–100 value, or a named level) onto OpenAI's realtime
|
|
27
|
+
* {@link RealtimeReasoningEffort} union. This is the OpenAI implementation of the
|
|
28
|
+
* {@link OpenAIRealtimeProfile.mapEffortLevel} seam — providers with a DIFFERENT effort vocabulary
|
|
29
|
+
* override the profile function rather than the protocol code.
|
|
30
|
+
*
|
|
31
|
+
* Numeric mapping is quintile-based across OpenAI's five levels: ≤20 → `minimal`, ≤40 → `low`,
|
|
32
|
+
* ≤60 → `medium`, ≤80 → `high`, >80 → `xhigh`. Named values already in the union pass through.
|
|
33
|
+
* Unmappable values return `undefined` (dropped with a diag log — never sent raw).
|
|
34
|
+
*
|
|
35
|
+
* @param effortLevel The MJ-normalized effort level (numeric string/number 1–100 or named level).
|
|
36
|
+
* @returns The provider effort literal, or `undefined` when the value cannot be mapped.
|
|
37
|
+
*/
|
|
38
|
+
export function MapEffortLevelToOpenAIRealtime(effortLevel) {
|
|
39
|
+
const named = effortLevel.trim().toLowerCase();
|
|
40
|
+
if (REALTIME_REASONING_EFFORTS.has(named)) {
|
|
41
|
+
return named;
|
|
42
|
+
}
|
|
43
|
+
const numValue = Number.parseInt(named, 10);
|
|
44
|
+
// A non-numeric OR non-positive value is nonsensical for a 1–100 scale — drop it (no override)
|
|
45
|
+
// rather than silently flooring 0/negatives to a real 'minimal' reasoning setting.
|
|
46
|
+
if (Number.isNaN(numValue) || numValue <= 0) {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
if (numValue <= 20)
|
|
50
|
+
return 'minimal';
|
|
51
|
+
if (numValue <= 40)
|
|
52
|
+
return 'low';
|
|
53
|
+
if (numValue <= 60)
|
|
54
|
+
return 'medium';
|
|
55
|
+
if (numValue <= 80)
|
|
56
|
+
return 'high';
|
|
57
|
+
return 'xhigh';
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Maps a GA per-modality usage-detail block onto the Core {@link RealtimeUsageModalityDetail}
|
|
61
|
+
* shape. Returns `undefined` when the provider reported no detail block (totals-only flows).
|
|
62
|
+
*/
|
|
63
|
+
export function MapUsageModalityDetail(detail) {
|
|
64
|
+
if (!detail) {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
const mapped = {};
|
|
68
|
+
if (typeof detail.text_tokens === 'number')
|
|
69
|
+
mapped.TextTokens = detail.text_tokens;
|
|
70
|
+
if (typeof detail.audio_tokens === 'number')
|
|
71
|
+
mapped.AudioTokens = detail.audio_tokens;
|
|
72
|
+
if (typeof detail.image_tokens === 'number')
|
|
73
|
+
mapped.ImageTokens = detail.image_tokens;
|
|
74
|
+
if (typeof detail.cached_tokens === 'number')
|
|
75
|
+
mapped.CachedTokens = detail.cached_tokens;
|
|
76
|
+
return Object.keys(mapped).length > 0 ? mapped : undefined;
|
|
77
|
+
}
|
|
78
|
+
/** The OpenAI provider profile — the defaults every OpenAI-compatible subclass overrides from. */
|
|
79
|
+
export const OPENAI_REALTIME_PROFILE = {
|
|
80
|
+
providerKey: 'openai',
|
|
81
|
+
inputTranscriptionModel: OPENAI_INPUT_TRANSCRIPTION_MODEL,
|
|
82
|
+
deferInitialConfigUntilSessionCreated: true,
|
|
83
|
+
foldInitialContextIntoPrompt: false,
|
|
84
|
+
supportsReasoningEffort: true,
|
|
85
|
+
supportsParallelToolCalls: true,
|
|
86
|
+
supportsMcpTools: true,
|
|
87
|
+
supportsVoiceOutput: true,
|
|
88
|
+
supportsLiveReconfigure: true,
|
|
89
|
+
unexpectedCloseMessage: 'OpenAI realtime connection closed unexpectedly',
|
|
90
|
+
// OpenAI's default turn detection (server VAD with auto-response) is correct for 1:1 calls, so
|
|
91
|
+
// the block is only sent when meeting mode needs create_response disabled.
|
|
92
|
+
buildTurnDetection: (disableAutoResponse) => disableAutoResponse ? { type: 'server_vad', create_response: false, interrupt_response: true } : undefined,
|
|
93
|
+
mapEffortLevel: MapEffortLevelToOpenAIRealtime,
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* Pulls the MJ-idiomatic feature keys OUT of the open Config bag so they are (a) translated to
|
|
97
|
+
* their provider-native session fields only when the profile confirms support, and (b) NEVER
|
|
98
|
+
* leaked raw into a provider payload that would reject unknown fields.
|
|
99
|
+
*
|
|
100
|
+
* Recognized bag keys: `effortLevel` (MJ-normalized: numeric 1–100 or named), `reasoningEffort`
|
|
101
|
+
* (provider-native literal — wins over `effortLevel` when both are present), `parallelToolCalls`,
|
|
102
|
+
* `mcpTools`, `voice`, `disableAutoResponse`. Everything else passes through in `rest`
|
|
103
|
+
* (provider-native keys like `tool_choice` or `output_modalities` can be set directly by config
|
|
104
|
+
* authors).
|
|
105
|
+
*
|
|
106
|
+
* @param config The open session Config bag (may be undefined).
|
|
107
|
+
* @returns The extracted features plus the residual bag.
|
|
108
|
+
*/
|
|
109
|
+
export function ExtractRealtimeFeatures(config) {
|
|
110
|
+
const rest = { ...(config ?? {}) };
|
|
111
|
+
// The provider-native key is an explicit override; the normalized key is the standard channel.
|
|
112
|
+
// Both are scrubbed either way so neither ever leaks raw into a provider payload. Numbers are
|
|
113
|
+
// accepted on effortLevel (ChatParams.effortLevel is a string, but config authors write JSON).
|
|
114
|
+
const rawNative = rest.reasoningEffort;
|
|
115
|
+
delete rest.reasoningEffort;
|
|
116
|
+
const rawNormalized = rest.effortLevel;
|
|
117
|
+
delete rest.effortLevel;
|
|
118
|
+
let effortLevel;
|
|
119
|
+
if (typeof rawNative === 'string' && rawNative.trim().length > 0) {
|
|
120
|
+
effortLevel = rawNative.trim();
|
|
121
|
+
}
|
|
122
|
+
else if (typeof rawNormalized === 'string' && rawNormalized.trim().length > 0) {
|
|
123
|
+
effortLevel = rawNormalized.trim();
|
|
124
|
+
}
|
|
125
|
+
else if (typeof rawNormalized === 'number' && Number.isFinite(rawNormalized)) {
|
|
126
|
+
effortLevel = String(rawNormalized);
|
|
127
|
+
}
|
|
128
|
+
const rawParallel = rest.parallelToolCalls;
|
|
129
|
+
delete rest.parallelToolCalls;
|
|
130
|
+
const parallelToolCalls = typeof rawParallel === 'boolean' ? rawParallel : undefined;
|
|
131
|
+
const rawMcp = rest.mcpTools;
|
|
132
|
+
delete rest.mcpTools;
|
|
133
|
+
const mcpTools = Array.isArray(rawMcp) && rawMcp.length > 0 ? rawMcp : undefined;
|
|
134
|
+
const rawVoice = rest.voice;
|
|
135
|
+
delete rest.voice;
|
|
136
|
+
const trimmedVoice = typeof rawVoice === 'string' ? rawVoice.trim() : '';
|
|
137
|
+
const voice = trimmedVoice.length > 0 ? trimmedVoice : undefined;
|
|
138
|
+
const disableAutoResponse = rest.disableAutoResponse === true;
|
|
139
|
+
delete rest.disableAutoResponse;
|
|
140
|
+
// PROTECTED WIRE FIELDS — never overridable through the open bag. `type` is the GA session
|
|
141
|
+
// discriminator (a clobbered value makes strict endpoints reject the WHOLE session.update,
|
|
142
|
+
// silently dropping the prompt AND tools); `instructions` is the server-authored co-agent
|
|
143
|
+
// identity; `tools` is the server-authored tool authority. `audio` remains an intentional,
|
|
144
|
+
// documented override channel.
|
|
145
|
+
const protectedBag = rest;
|
|
146
|
+
if (protectedBag.type !== undefined || protectedBag.instructions !== undefined || protectedBag.tools !== undefined || protectedBag.model !== undefined) {
|
|
147
|
+
RealtimeDiagLog('[OpenAIRealtime][diag] Scrubbing protected wire field(s) (type/instructions/tools/model) from the session Config bag — these are server-authored and cannot be overridden per session');
|
|
148
|
+
}
|
|
149
|
+
delete protectedBag.type;
|
|
150
|
+
delete protectedBag.instructions;
|
|
151
|
+
delete protectedBag.tools;
|
|
152
|
+
// `model` is server-authoritative on the client-direct minted session (set from params.Model) —
|
|
153
|
+
// a bag override would let a browser pin a different model in the ephemeral pact.
|
|
154
|
+
delete protectedBag.model;
|
|
155
|
+
// Per-session transcription-model override + MJ-side transport settings. All scrubbed
|
|
156
|
+
// unconditionally — none of these are wire fields on ANY provider in the family.
|
|
157
|
+
const bag = rest;
|
|
158
|
+
const rawItm = bag.inputTranscriptionModel;
|
|
159
|
+
delete bag.inputTranscriptionModel;
|
|
160
|
+
const inputTranscriptionModel = typeof rawItm === 'string' && rawItm.trim().length > 0 ? rawItm.trim() : undefined;
|
|
161
|
+
const rawEndpoint = bag.endpoint;
|
|
162
|
+
delete bag.endpoint;
|
|
163
|
+
const endpoint = typeof rawEndpoint === 'string' && rawEndpoint.trim().length > 0 ? rawEndpoint.trim() : undefined;
|
|
164
|
+
const rawRate = bag.sampleRate;
|
|
165
|
+
delete bag.sampleRate;
|
|
166
|
+
const sampleRate = typeof rawRate === 'number' && rawRate > 0 ? rawRate : undefined;
|
|
167
|
+
const rawProxy = bag.proxyBaseUrl;
|
|
168
|
+
delete bag.proxyBaseUrl;
|
|
169
|
+
const proxyBaseUrl = typeof rawProxy === 'string' && rawProxy.trim().length > 0 ? rawProxy.trim() : undefined;
|
|
170
|
+
return { effortLevel, parallelToolCalls, mcpTools, voice, disableAutoResponse, inputTranscriptionModel, endpoint, sampleRate, proxyBaseUrl, rest };
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Applies the profile-gated GA features onto a session payload. Features a provider has not
|
|
174
|
+
* confirmed are silently dropped (already scrubbed from the bag by {@link ExtractRealtimeFeatures})
|
|
175
|
+
* rather than sent and rejected. Effort levels run through the profile's `mapEffortLevel` seam so
|
|
176
|
+
* each provider translates MJ's normalized vocabulary to its own literals.
|
|
177
|
+
*
|
|
178
|
+
* @param session The session payload under construction.
|
|
179
|
+
* @param features The features extracted from the Config bag.
|
|
180
|
+
* @param profile The provider profile gating each feature.
|
|
181
|
+
*/
|
|
182
|
+
function applyGAFeatures(session, features, profile) {
|
|
183
|
+
if (profile.supportsReasoningEffort && features.effortLevel) {
|
|
184
|
+
const mapped = profile.mapEffortLevel(features.effortLevel);
|
|
185
|
+
if (mapped) {
|
|
186
|
+
session.reasoning = { effort: mapped };
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
RealtimeDiagLog(`[${profile.providerKey}Realtime][diag] Ignoring unmappable effort level '${features.effortLevel}'`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (profile.supportsParallelToolCalls && features.parallelToolCalls !== undefined) {
|
|
193
|
+
session.parallel_tool_calls = features.parallelToolCalls;
|
|
194
|
+
}
|
|
195
|
+
if (profile.supportsMcpTools && features.mcpTools && features.mcpTools.length > 0) {
|
|
196
|
+
// MCP server tools ride ALONGSIDE the function tools — the GA tools array is a union of both.
|
|
197
|
+
// NOTE: the driver has no MCP approval UX yet, so config authors should declare servers with
|
|
198
|
+
// `require_approval: 'never'`; an mcp_approval_request arriving mid-session is surfaced as a
|
|
199
|
+
// recoverable session error (see OpenAIRealtimeSession.dispatch) rather than silently stalling.
|
|
200
|
+
session.tools = [...(session.tools ?? []), ...features.mcpTools];
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Assembles the session `audio` block from the profile + extracted features, or `undefined` when
|
|
205
|
+
* every part is empty (compat endpoints reject/ignore hollow blocks). The transcription model is
|
|
206
|
+
* the per-session bag override when present, else the profile's default (which may be undefined
|
|
207
|
+
* for natively-transcribing providers).
|
|
208
|
+
*
|
|
209
|
+
* @param profile The provider profile.
|
|
210
|
+
* @param features The extracted Config-bag features.
|
|
211
|
+
* @param turnDetection The already-built turn-detection block, if any.
|
|
212
|
+
* @returns The audio block, or `undefined` to omit it.
|
|
213
|
+
*/
|
|
214
|
+
function BuildAudioBlock(profile, features, turnDetection) {
|
|
215
|
+
const transcriptionModel = features.inputTranscriptionModel ?? profile.inputTranscriptionModel;
|
|
216
|
+
const input = {
|
|
217
|
+
...(transcriptionModel ? { transcription: { model: transcriptionModel } } : {}),
|
|
218
|
+
...(turnDetection ? { turn_detection: turnDetection } : {}),
|
|
219
|
+
};
|
|
220
|
+
const output = profile.supportsVoiceOutput && features.voice ? { voice: features.voice } : undefined;
|
|
221
|
+
if (Object.keys(input).length === 0 && !output) {
|
|
222
|
+
return undefined;
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
...(Object.keys(input).length > 0 ? { input } : {}),
|
|
226
|
+
...(output ? { output } : {}),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
22
229
|
/**
|
|
23
230
|
* Maps Core {@link RealtimeToolDefinition}s up to OpenAI's native function-tool schema.
|
|
24
231
|
*
|
|
@@ -46,31 +253,65 @@ function mapRealtimeTools(tools) {
|
|
|
46
253
|
* context), and translates the provider's server-event stream into the modality-agnostic
|
|
47
254
|
* {@link IRealtimeSession} contract.
|
|
48
255
|
*
|
|
256
|
+
* **This class is also the shared implementation for OpenAI-Realtime-compatible providers.**
|
|
257
|
+
* Compatible providers (e.g. xAI Grok Voice) subclass it, pass their base URL to the constructor,
|
|
258
|
+
* and override {@link OpenAIRealtime.Profile} — inheriting the whole protocol implementation and
|
|
259
|
+
* every future GA feature (gated per-provider by the profile) instead of maintaining a clone.
|
|
260
|
+
*
|
|
261
|
+
* **GA features** (gpt-realtime-2 / 2.1 era) are driven from the open
|
|
262
|
+
* {@link RealtimeSessionParams.Config} bag with MJ-idiomatic keys, translated to provider-native
|
|
263
|
+
* session fields only when the profile confirms support:
|
|
264
|
+
* - `reasoningEffort: 'minimal'|'low'|'medium'|'high'|'xhigh'` → `reasoning.effort`
|
|
265
|
+
* - `parallelToolCalls: boolean` → `parallel_tool_calls`
|
|
266
|
+
* - `mcpTools: [{ type:'mcp', server_label, server_url|connector_id, ... }]` → appended to `session.tools`
|
|
267
|
+
*
|
|
49
268
|
* **Tool results** complete the tool-call loop: the returned session implements the Core
|
|
50
269
|
* `IRealtimeSession.SendToolResult` contract method, which the agent layer calls after executing a
|
|
51
270
|
* tool to feed its result back to the model. See {@link OpenAIRealtimeSession.SendToolResult}.
|
|
52
271
|
*/
|
|
53
272
|
let OpenAIRealtime = class OpenAIRealtime extends BaseRealtimeModel {
|
|
54
|
-
|
|
273
|
+
/**
|
|
274
|
+
* @param apiKey The provider API key.
|
|
275
|
+
* @param baseURL Optional override for OpenAI-compatible providers (subclasses pass their own
|
|
276
|
+
* endpoint; the SDK's `buildRealtimeURL()` derives the wss:// realtime endpoint from it).
|
|
277
|
+
*/
|
|
278
|
+
constructor(apiKey, baseURL) {
|
|
55
279
|
super(apiKey);
|
|
56
|
-
this._openAI = new OpenAI({ apiKey });
|
|
280
|
+
this._openAI = baseURL ? new OpenAI({ apiKey, baseURL }) : new OpenAI({ apiKey });
|
|
57
281
|
}
|
|
58
282
|
/** Read-only accessor for the underlying OpenAI SDK client. */
|
|
59
283
|
get OpenAI() {
|
|
60
284
|
return this._openAI;
|
|
61
285
|
}
|
|
286
|
+
/**
|
|
287
|
+
* The provider profile driving per-provider knobs and GA feature gates. Subclasses override
|
|
288
|
+
* this single seam instead of re-implementing the protocol.
|
|
289
|
+
*/
|
|
290
|
+
get Profile() {
|
|
291
|
+
return OPENAI_REALTIME_PROFILE;
|
|
292
|
+
}
|
|
62
293
|
/**
|
|
63
294
|
* Creates the realtime connection for a model. Overridable seam for testing.
|
|
64
295
|
*
|
|
65
296
|
* Production returns a real `OpenAIRealtimeWebSocket`. Unit tests override this to return a
|
|
66
297
|
* fake {@link IOpenAIRealtimeConnection} that emits OpenAI-shaped events and captures sends.
|
|
67
298
|
*
|
|
68
|
-
* @param model The provider realtime model id (e.g. `gpt-realtime`).
|
|
299
|
+
* @param model The provider realtime model id (e.g. `gpt-realtime-2.1`).
|
|
69
300
|
* @returns A connection implementing {@link IOpenAIRealtimeConnection}.
|
|
70
301
|
*/
|
|
71
302
|
createConnection(model) {
|
|
72
303
|
return new OpenAIRealtimeWebSocket({ model }, this._openAI);
|
|
73
304
|
}
|
|
305
|
+
/**
|
|
306
|
+
* Creates the session wrapper for a freshly-opened connection. Overridable seam so subclasses
|
|
307
|
+
* can return their own session subclass while {@link StartSession} stays shared.
|
|
308
|
+
*
|
|
309
|
+
* @param connection The open provider connection.
|
|
310
|
+
* @returns The session bound to this driver's {@link Profile}.
|
|
311
|
+
*/
|
|
312
|
+
createSessionInstance(connection) {
|
|
313
|
+
return new OpenAIRealtimeSession(connection, this.Profile);
|
|
314
|
+
}
|
|
74
315
|
/**
|
|
75
316
|
* Opens a duplex realtime session, applies the session config, and returns the live handle.
|
|
76
317
|
*
|
|
@@ -79,7 +320,7 @@ let OpenAIRealtime = class OpenAIRealtime extends BaseRealtimeModel {
|
|
|
79
320
|
*/
|
|
80
321
|
async StartSession(params) {
|
|
81
322
|
const connection = this.createConnection(params.Model);
|
|
82
|
-
const session =
|
|
323
|
+
const session = this.createSessionInstance(connection);
|
|
83
324
|
session.applyInitialConfig(params);
|
|
84
325
|
return session;
|
|
85
326
|
}
|
|
@@ -108,11 +349,12 @@ let OpenAIRealtime = class OpenAIRealtime extends BaseRealtimeModel {
|
|
|
108
349
|
];
|
|
109
350
|
}
|
|
110
351
|
/**
|
|
111
|
-
* Mints the ephemeral client secret via
|
|
112
|
-
*
|
|
352
|
+
* Mints the ephemeral client secret via the provider's Realtime client-secrets API (resolved
|
|
353
|
+
* from the SDK client's base URL, so OpenAI-compatible subclasses target their own endpoint).
|
|
354
|
+
* Overridable seam for testing — unit tests return a fake response so no network call is made.
|
|
113
355
|
*
|
|
114
356
|
* @param body The client-secret create request (carries the realtime session config).
|
|
115
|
-
* @returns The
|
|
357
|
+
* @returns The client-secret create response (token value + expiry + echoed session).
|
|
116
358
|
*/
|
|
117
359
|
async mintClientSecret(body) {
|
|
118
360
|
return this._openAI.realtime.clientSecrets.create(body);
|
|
@@ -123,32 +365,42 @@ let OpenAIRealtime = class OpenAIRealtime extends BaseRealtimeModel {
|
|
|
123
365
|
* (system prompt + tools + model) so it retains control of behavior even though the browser
|
|
124
366
|
* owns the socket.
|
|
125
367
|
*
|
|
368
|
+
* The GA features (reasoning effort, parallel tool calls, MCP tools) and the output voice are
|
|
369
|
+
* extracted from the Config bag and applied here exactly as on the server-bridged path, so the
|
|
370
|
+
* two topologies stay behaviorally identical — the browser applies the minted SessionConfig
|
|
371
|
+
* verbatim with no client-side changes needed.
|
|
372
|
+
*
|
|
126
373
|
* @param params Session configuration (model, system prompt, tools).
|
|
127
374
|
* @returns The minted {@link ClientRealtimeSessionConfig} the browser authenticates + applies.
|
|
128
375
|
*/
|
|
129
376
|
async CreateClientSession(params) {
|
|
377
|
+
const profile = this.Profile;
|
|
378
|
+
const features = ExtractRealtimeFeatures(params.Config);
|
|
379
|
+
// Enable transcription of the user's mic input so BOTH sides of the conversation are
|
|
380
|
+
// captured (live captions + persisted ConversationDetail turns). Realtime models accept
|
|
381
|
+
// audio natively, so input transcription is a separate ASR pass that must be opted into.
|
|
382
|
+
// The OUTPUT voice comes from the effective config's per-provider voice (`params.Config.voice`,
|
|
383
|
+
// shaped by GetProviderVoiceSettings) — this is what lets a co-agent's configured voice OR a
|
|
384
|
+
// per-session override actually take effect in the client-direct topology.
|
|
385
|
+
const turnDetection = profile.buildTurnDetection(features.disableAutoResponse);
|
|
386
|
+
const audio = BuildAudioBlock(profile, features, turnDetection);
|
|
130
387
|
const session = {
|
|
131
388
|
type: 'realtime',
|
|
132
389
|
model: params.Model,
|
|
133
390
|
instructions: params.SystemPrompt,
|
|
391
|
+
...(audio ? { audio } : {}),
|
|
392
|
+
// The residual (feature-scrubbed, wire-field-protected) Config bag applies here EXACTLY
|
|
393
|
+
// as on the server-bridged session.update — same construction ORDER too, so a raw
|
|
394
|
+
// `audio` override behaves identically on both topologies.
|
|
395
|
+
...features.rest,
|
|
134
396
|
};
|
|
135
397
|
if (params.Tools && params.Tools.length > 0) {
|
|
136
398
|
session.tools = mapRealtimeTools(params.Tools);
|
|
137
399
|
}
|
|
138
|
-
|
|
139
|
-
// captured (live captions + persisted ConversationDetail turns). Realtime models accept
|
|
140
|
-
// audio natively, so input transcription is a separate ASR pass that must be opted into.
|
|
141
|
-
// The OUTPUT voice comes from the effective config's per-provider voice (`params.Config.voice`,
|
|
142
|
-
// shaped by GetProviderVoiceSettings) — this is what lets a co-agent's configured voice OR a
|
|
143
|
-
// per-session override actually take effect in the client-direct topology.
|
|
144
|
-
const voice = params.Config?.voice;
|
|
145
|
-
session.audio = {
|
|
146
|
-
input: { transcription: { model: OPENAI_INPUT_TRANSCRIPTION_MODEL } },
|
|
147
|
-
...(voice && voice.trim().length > 0 ? { output: { voice: voice.trim() } } : {}),
|
|
148
|
-
};
|
|
400
|
+
applyGAFeatures(session, features, profile);
|
|
149
401
|
const response = await this.mintClientSecret({ session });
|
|
150
402
|
return {
|
|
151
|
-
Provider:
|
|
403
|
+
Provider: profile.providerKey,
|
|
152
404
|
Model: params.Model,
|
|
153
405
|
EphemeralToken: response.value,
|
|
154
406
|
ExpiresAt: new Date(response.expires_at * 1000).toISOString(),
|
|
@@ -159,7 +411,7 @@ let OpenAIRealtime = class OpenAIRealtime extends BaseRealtimeModel {
|
|
|
159
411
|
};
|
|
160
412
|
OpenAIRealtime = __decorate([
|
|
161
413
|
RegisterClass(BaseRealtimeModel, 'OpenAIRealtime'),
|
|
162
|
-
__metadata("design:paramtypes", [String])
|
|
414
|
+
__metadata("design:paramtypes", [String, String])
|
|
163
415
|
], OpenAIRealtime);
|
|
164
416
|
export { OpenAIRealtime };
|
|
165
417
|
/**
|
|
@@ -167,11 +419,24 @@ export { OpenAIRealtime };
|
|
|
167
419
|
*
|
|
168
420
|
* Holds the registered handlers and the single `'event'` listener that fans the provider's
|
|
169
421
|
* server-event stream out to the contract handlers via {@link OpenAIRealtimeSession.dispatch}.
|
|
422
|
+
*
|
|
423
|
+
* The session is profile-parameterized (see {@link OpenAIRealtimeProfile}) so OpenAI-compatible
|
|
424
|
+
* provider subclasses reuse it verbatim with their own knobs.
|
|
170
425
|
*/
|
|
171
426
|
export class OpenAIRealtimeSession {
|
|
172
|
-
|
|
427
|
+
/**
|
|
428
|
+
* @param connection The injectable provider-connection seam.
|
|
429
|
+
* @param profile The provider profile (defaults to OpenAI's so existing direct construction keeps working).
|
|
430
|
+
*/
|
|
431
|
+
constructor(connection, profile = OPENAI_REALTIME_PROFILE) {
|
|
173
432
|
/** Set by {@link Close} so a consumer-initiated teardown never reports an "unexpected" close. */
|
|
174
433
|
this.closedByConsumer = false;
|
|
434
|
+
this.resolveConfigApplied = null;
|
|
435
|
+
this.rejectConfigApplied = null;
|
|
436
|
+
/** The deferred-config listener awaiting `session.created`, tracked so teardown can remove it. */
|
|
437
|
+
this.pendingConfigListener = null;
|
|
438
|
+
/** Deadline timer for the deferred-config readiness wait (see {@link configReadinessTimeoutMs}). */
|
|
439
|
+
this.configReadinessTimer = null;
|
|
175
440
|
/**
|
|
176
441
|
* Whether a model response is currently in flight. Minimal response tracking that mirrors the
|
|
177
442
|
* client driver's state machine: set on `response.created` (and eagerly whenever this session
|
|
@@ -180,9 +445,46 @@ export class OpenAIRealtimeSession {
|
|
|
180
445
|
* `cancelled` after barge-in, so the flag can never stick. Consumed by
|
|
181
446
|
* {@link OpenAIRealtimeSession.RequestSpokenUpdate} to skip (not collide with) an active
|
|
182
447
|
* response, since the API rejects overlapping `response.create` requests.
|
|
448
|
+
*
|
|
449
|
+
* Protected (not private) so compat-endpoint session subclasses can apply provider-specific
|
|
450
|
+
* robustness tweaks (e.g. HuggingFace marks a response active on the first audio delta and
|
|
451
|
+
* releases the flag when a tool call yields the floor).
|
|
183
452
|
*/
|
|
184
453
|
this.responseActive = false;
|
|
454
|
+
/**
|
|
455
|
+
* Whether the CURRENT user turn has already produced at least one finalized input transcription.
|
|
456
|
+
* Streamed-transcription providers (Grok) emit `input_audio_transcription.completed` REPEATEDLY
|
|
457
|
+
* for one utterance, each carrying the full growing text; without this flag every repeat lands as
|
|
458
|
+
* a fresh non-replacing final and the persistence layer mints a duplicate `ConversationDetail`
|
|
459
|
+
* row per caption. The second-and-later completeds are flagged {@link RealtimeTranscript.ReplacesPrevious}
|
|
460
|
+
* so they REPLACE the turn's row in place — exactly the client-direct driver's behavior, kept in
|
|
461
|
+
* sync here so the two topologies persist identically. Reset on each `speech_started` (new turn).
|
|
462
|
+
* Harmless for single-completed providers (OpenAI): the flag is always false on the one completed.
|
|
463
|
+
*/
|
|
464
|
+
this.userTurnTranscribed = false;
|
|
465
|
+
/**
|
|
466
|
+
* Text of the CURRENT user turn's most recent finalized transcription, used to detect that a new
|
|
467
|
+
* `completed` is the same utterance continuing rather than a new turn.
|
|
468
|
+
*
|
|
469
|
+
* Streaming-transcription providers re-emit the FULL accumulated utterance on every `completed`,
|
|
470
|
+
* and their VAD fires `speech_started` on ordinary mid-sentence pauses. Keying the turn boundary
|
|
471
|
+
* solely off `speech_started` therefore splits one spoken thought into several turns, each a longer
|
|
472
|
+
* copy of the last. Comparing against this text (via {@link IsTranscriptContinuation}, which
|
|
473
|
+
* normalizes punctuation because ASR re-punctuates as a sentence grows) lets a post-pause caption
|
|
474
|
+
* be recognized as a continuation and REPLACE the turn in place instead.
|
|
475
|
+
*
|
|
476
|
+
* Cleared when the model starts responding (`response.created`), which is the real end of the
|
|
477
|
+
* user's turn — so two genuinely separate utterances can never be merged across a model reply.
|
|
478
|
+
*/
|
|
479
|
+
this.lastUserTranscript = '';
|
|
185
480
|
this.connection = connection;
|
|
481
|
+
this.profile = profile;
|
|
482
|
+
this.configAppliedPromise = new Promise((resolve, reject) => {
|
|
483
|
+
this.resolveConfigApplied = resolve;
|
|
484
|
+
this.rejectConfigApplied = reject;
|
|
485
|
+
});
|
|
486
|
+
// Not every consumer awaits WaitForConfigApplied — guard unhandled-rejection noise.
|
|
487
|
+
this.configAppliedPromise.catch(() => undefined);
|
|
186
488
|
this.eventListener = (event) => this.dispatch(event);
|
|
187
489
|
this.connection.on('event', this.eventListener);
|
|
188
490
|
this.errorListener = (error) => this.handleConnectionError(error);
|
|
@@ -195,17 +497,37 @@ export class OpenAIRealtimeSession {
|
|
|
195
497
|
* Applies the initial session config: system prompt + tools via `session.update`, optional initial
|
|
196
498
|
* context as a user message. Called once by {@link OpenAIRealtime.StartSession}.
|
|
197
499
|
*
|
|
198
|
-
* **
|
|
199
|
-
* — sending `session.update` synchronously races the handshake and the instructions (the
|
|
200
|
-
* prompt + tools**) are silently dropped, so the model runs with NO prompt (no identity, no
|
|
201
|
-
* framing). We therefore wait for the server's `session.created` frame — the first event once
|
|
202
|
-
* is open and the session exists, and the canonical moment to configure a realtime session —
|
|
203
|
-
* point the browser/client-direct path applies its config. Idempotent (a re-emitted
|
|
204
|
-
* can't double-apply); the listener removes itself once it fires.
|
|
500
|
+
* **Deferral is profile-driven.** On OpenAI the realtime WebSocket is NOT open when `StartSession`
|
|
501
|
+
* returns — sending `session.update` synchronously races the handshake and the instructions (the
|
|
502
|
+
* **system prompt + tools**) are silently dropped, so the model runs with NO prompt (no identity, no
|
|
503
|
+
* companion framing). We therefore wait for the server's `session.created` frame — the first event once
|
|
504
|
+
* the socket is open and the session exists, and the canonical moment to configure a realtime session —
|
|
505
|
+
* exactly the point the browser/client-direct path applies its config. Idempotent (a re-emitted
|
|
506
|
+
* `session.created` can't double-apply); the listener removes itself once it fires. Providers whose
|
|
507
|
+
* socket accepts config immediately (xAI) set the profile flag false and send synchronously.
|
|
205
508
|
*
|
|
206
509
|
* @param params The session parameters.
|
|
207
510
|
*/
|
|
208
511
|
applyInitialConfig(params) {
|
|
512
|
+
// Compat endpoints with no history-seeding channel fold the prior context into the system
|
|
513
|
+
// prompt instead of seeding a separate user message (profile-driven).
|
|
514
|
+
const fold = this.profile.foldInitialContextIntoPrompt;
|
|
515
|
+
const context = params.InitialContext?.trim();
|
|
516
|
+
const systemPrompt = fold && context ? `${params.SystemPrompt}\n\n## Prior context\n${context}` : params.SystemPrompt;
|
|
517
|
+
const applyConfig = () => {
|
|
518
|
+
this.sendSessionUpdate(systemPrompt, params.Tools, params.Config);
|
|
519
|
+
if (!fold && context && context.length > 0) {
|
|
520
|
+
this.sendInitialContext(context);
|
|
521
|
+
}
|
|
522
|
+
this.clearConfigReadinessTimer();
|
|
523
|
+
this.resolveConfigApplied?.();
|
|
524
|
+
this.resolveConfigApplied = null;
|
|
525
|
+
this.rejectConfigApplied = null;
|
|
526
|
+
};
|
|
527
|
+
if (!this.profile.deferInitialConfigUntilSessionCreated) {
|
|
528
|
+
applyConfig();
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
209
531
|
let applied = false;
|
|
210
532
|
const applyWhenReady = (event) => {
|
|
211
533
|
if (applied || event.type !== 'session.created') {
|
|
@@ -213,12 +535,77 @@ export class OpenAIRealtimeSession {
|
|
|
213
535
|
}
|
|
214
536
|
applied = true;
|
|
215
537
|
this.connection.off('event', applyWhenReady);
|
|
216
|
-
this.
|
|
217
|
-
|
|
218
|
-
this.sendInitialContext(params.InitialContext);
|
|
219
|
-
}
|
|
538
|
+
this.pendingConfigListener = null;
|
|
539
|
+
applyConfig();
|
|
220
540
|
};
|
|
541
|
+
this.pendingConfigListener = applyWhenReady;
|
|
221
542
|
this.connection.on('event', applyWhenReady);
|
|
543
|
+
// Readiness deadline: a silent endpoint (socket open, no session.created) must not hang a
|
|
544
|
+
// driver that AWAITS WaitForConfigApplied (HuggingFace) forever. The timeout rejects the
|
|
545
|
+
// WAIT only — the deferred listener stays registered, so a late session.created on a
|
|
546
|
+
// fire-and-forget flow (OpenAI's non-awaiting StartSession) still applies the config.
|
|
547
|
+
this.configReadinessTimer = setTimeout(() => {
|
|
548
|
+
this.configReadinessTimer = null;
|
|
549
|
+
this.failConfigWaitOnly(`session.created not received within ${this.configReadinessTimeoutMs}ms — endpoint silent during startup`);
|
|
550
|
+
}, this.configReadinessTimeoutMs);
|
|
551
|
+
// Node-only nicety: never let a readiness timer keep the process alive (browser bundles
|
|
552
|
+
// of this server package don't exist; unref is feature-detected anyway).
|
|
553
|
+
this.configReadinessTimer.unref?.();
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Readiness deadline in milliseconds for the deferred-config wait. Only affects consumers of
|
|
557
|
+
* {@link WaitForConfigApplied}; the deferred apply itself is not cancelled. Overridable.
|
|
558
|
+
*/
|
|
559
|
+
get configReadinessTimeoutMs() {
|
|
560
|
+
return 15_000;
|
|
561
|
+
}
|
|
562
|
+
/** Rejects a pending config wait WITHOUT removing the deferred listener (timeout semantics). */
|
|
563
|
+
failConfigWaitOnly(message) {
|
|
564
|
+
if (this.rejectConfigApplied) {
|
|
565
|
+
const reject = this.rejectConfigApplied;
|
|
566
|
+
this.rejectConfigApplied = null;
|
|
567
|
+
this.resolveConfigApplied = null;
|
|
568
|
+
reject(new Error(message));
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
/** Clears the readiness-deadline timer (config applied, or session torn down). */
|
|
572
|
+
clearConfigReadinessTimer() {
|
|
573
|
+
if (this.configReadinessTimer) {
|
|
574
|
+
clearTimeout(this.configReadinessTimer);
|
|
575
|
+
this.configReadinessTimer = null;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
/** Removes a still-pending deferred-config listener (teardown before `session.created`). */
|
|
579
|
+
clearPendingConfigListener() {
|
|
580
|
+
if (this.pendingConfigListener) {
|
|
581
|
+
this.connection.off('event', this.pendingConfigListener);
|
|
582
|
+
this.pendingConfigListener = null;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Resolves once the initial session config has been APPLIED (sent on the socket) — immediately
|
|
587
|
+
* for providers that configure synchronously, or on the server's `session.created` frame for
|
|
588
|
+
* deferring providers. Rejects if the transport dies (fatal error or unexpected close) or the
|
|
589
|
+
* consumer closes the session before the config went out.
|
|
590
|
+
*
|
|
591
|
+
* The base {@link OpenAIRealtime.StartSession} deliberately does NOT await this (OpenAI
|
|
592
|
+
* semantics: the session handle is returned while the handshake completes). Drivers whose
|
|
593
|
+
* contract promises "ready only after config is applied" (HuggingFace) await it in their
|
|
594
|
+
* `StartSession` override.
|
|
595
|
+
*/
|
|
596
|
+
WaitForConfigApplied() {
|
|
597
|
+
return this.configAppliedPromise;
|
|
598
|
+
}
|
|
599
|
+
/** Rejects a still-pending {@link WaitForConfigApplied} (transport death / early consumer close). */
|
|
600
|
+
failConfigWait(message) {
|
|
601
|
+
this.clearConfigReadinessTimer();
|
|
602
|
+
this.clearPendingConfigListener();
|
|
603
|
+
if (this.rejectConfigApplied) {
|
|
604
|
+
const reject = this.rejectConfigApplied;
|
|
605
|
+
this.rejectConfigApplied = null;
|
|
606
|
+
this.resolveConfigApplied = null;
|
|
607
|
+
reject(new Error(message));
|
|
608
|
+
}
|
|
222
609
|
}
|
|
223
610
|
// ---- IRealtimeSession outbound ----
|
|
224
611
|
/** @inheritdoc */
|
|
@@ -295,11 +682,11 @@ export class OpenAIRealtimeSession {
|
|
|
295
682
|
*/
|
|
296
683
|
RequestSpokenUpdate(instructions) {
|
|
297
684
|
if (this.responseActive) {
|
|
298
|
-
RealtimeDiagLog(
|
|
685
|
+
RealtimeDiagLog(`[${this.profile.providerKey}Realtime][diag] RequestSpokenUpdate SKIPPED — a response is already active (interim updates are disposable)`);
|
|
299
686
|
return false; // NOT sent — the caller (bridge) releases the floor instead of wedging on it
|
|
300
687
|
}
|
|
301
688
|
this.responseActive = true;
|
|
302
|
-
RealtimeDiagLog(`[
|
|
689
|
+
RealtimeDiagLog(`[${this.profile.providerKey}Realtime][diag] RequestSpokenUpdate → sending response.create (perResponseInstructions=${typeof instructions === 'string' && instructions.trim().length > 0 ? 'yes' : 'none → session prompt governs'})`);
|
|
303
690
|
// CRITICAL: only set per-response `instructions` when the caller actually supplied some. OpenAI's
|
|
304
691
|
// `response.create` treats `response.instructions` as a FULL override of the session system prompt for
|
|
305
692
|
// that response — so forwarding `''` would wipe the co-agent identity framing (incl. the
|
|
@@ -309,9 +696,9 @@ export class OpenAIRealtimeSession {
|
|
|
309
696
|
this.connection.send(hasInstructions ? { type: 'response.create', response: { instructions } } : { type: 'response.create' });
|
|
310
697
|
return true; // a response.create was issued — the bridge may hold the floor for this turn
|
|
311
698
|
}
|
|
312
|
-
/** @inheritdoc —
|
|
699
|
+
/** @inheritdoc — profile-gated: only providers whose endpoint honors a live partial `session.update`. */
|
|
313
700
|
get Capabilities() {
|
|
314
|
-
return { CanReconfigureTurnMode:
|
|
701
|
+
return { CanReconfigureTurnMode: this.profile.supportsLiveReconfigure };
|
|
315
702
|
}
|
|
316
703
|
/**
|
|
317
704
|
* @inheritdoc
|
|
@@ -321,17 +708,29 @@ export class OpenAIRealtimeSession {
|
|
|
321
708
|
* transcription block is re-sent alongside so the partial update can't drop it.
|
|
322
709
|
*/
|
|
323
710
|
Reconfigure(params) {
|
|
711
|
+
if (!this.profile.supportsLiveReconfigure) {
|
|
712
|
+
// The profile declares no live-reconfigure support — advertising Capabilities false is
|
|
713
|
+
// the primary guard; this no-op is defense-in-depth against callers that skip the check.
|
|
714
|
+
RealtimeDiagLog(`[${this.profile.providerKey}Realtime][diag] Reconfigure ignored — profile declares no live turn-mode support`);
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
324
717
|
const disable = params.DisableAutoResponse === true;
|
|
325
718
|
const turnDetection = {
|
|
326
719
|
type: 'server_vad',
|
|
327
720
|
create_response: !disable,
|
|
328
721
|
interrupt_response: true,
|
|
329
722
|
};
|
|
723
|
+
// Re-send the transcription block alongside ONLY when this profile transcribes via an
|
|
724
|
+
// opt-in model — a partial update must not fabricate `transcription: { model: undefined }`
|
|
725
|
+
// for natively-transcribing providers.
|
|
726
|
+
const transcription = this.profile.inputTranscriptionModel
|
|
727
|
+
? { transcription: { model: this.profile.inputTranscriptionModel } }
|
|
728
|
+
: {};
|
|
330
729
|
this.connection.send({
|
|
331
730
|
type: 'session.update',
|
|
332
731
|
session: {
|
|
333
732
|
type: 'realtime',
|
|
334
|
-
audio: { input: { transcription
|
|
733
|
+
audio: { input: { ...transcription, turn_detection: turnDetection } },
|
|
335
734
|
},
|
|
336
735
|
});
|
|
337
736
|
}
|
|
@@ -367,6 +766,8 @@ export class OpenAIRealtimeSession {
|
|
|
367
766
|
/** @inheritdoc */
|
|
368
767
|
async Close() {
|
|
369
768
|
this.closedByConsumer = true;
|
|
769
|
+
this.failConfigWait('session closed by consumer before the initial config was applied');
|
|
770
|
+
this.clearPendingConfigListener();
|
|
370
771
|
this.connection.off('event', this.eventListener);
|
|
371
772
|
this.connection.off('error', this.errorListener);
|
|
372
773
|
this.connection.close();
|
|
@@ -376,6 +777,9 @@ export class OpenAIRealtimeSession {
|
|
|
376
777
|
* Routes a provider server event to the matching contract handler. Each branch delegates to a
|
|
377
778
|
* small, single-purpose handler to keep this dispatcher flat.
|
|
378
779
|
*
|
|
780
|
+
* Protected (not private) so OpenAI-compatible session subclasses can pre-translate legacy /
|
|
781
|
+
* beta frame aliases before delegating here.
|
|
782
|
+
*
|
|
379
783
|
* @param event The OpenAI realtime server event.
|
|
380
784
|
*/
|
|
381
785
|
dispatch(event) {
|
|
@@ -388,13 +792,35 @@ export class OpenAIRealtimeSession {
|
|
|
388
792
|
return this.emitTranscript('assistant', event.transcript, true);
|
|
389
793
|
case 'conversation.item.input_audio_transcription.delta':
|
|
390
794
|
return this.emitTranscript('user', event.delta ?? '', false);
|
|
391
|
-
case 'conversation.item.input_audio_transcription.completed':
|
|
392
|
-
|
|
795
|
+
case 'conversation.item.input_audio_transcription.completed': {
|
|
796
|
+
// Streamed transcription (Grok): the 2nd+ completed of a turn REPLACES the turn's row
|
|
797
|
+
// in place rather than appending a duplicate. Two ways a completed can be a replacement:
|
|
798
|
+
// 1. another completed already landed in THIS turn (userTurnTranscribed); or
|
|
799
|
+
// 2. it CONTINUES the previous utterance — the provider re-emitted the whole thing
|
|
800
|
+
// with more words on the end. This second case is what rescues a mid-sentence
|
|
801
|
+
// pause: the VAD fires speech_started (clearing the flag) even though the user
|
|
802
|
+
// never stopped talking, and without the continuation test that one spoken thought
|
|
803
|
+
// persists as several rows, each a longer copy of the last.
|
|
804
|
+
const text = event.transcript;
|
|
805
|
+
const replacesPrevious = this.userTurnTranscribed || IsTranscriptContinuation(this.lastUserTranscript, text);
|
|
806
|
+
this.userTurnTranscribed = true;
|
|
807
|
+
this.lastUserTranscript = text;
|
|
808
|
+
return this.emitTranscript('user', text, true, replacesPrevious);
|
|
809
|
+
}
|
|
393
810
|
case 'response.function_call_arguments.done':
|
|
394
811
|
return this.handleFunctionCall(event.call_id, event.name, event.arguments);
|
|
395
812
|
case 'input_audio_buffer.speech_started':
|
|
813
|
+
// A new user turn begins — reset the streamed-transcription flag so its first
|
|
814
|
+
// completed is a fresh (non-replacing) final. Do this UNCONDITIONALLY (not only on
|
|
815
|
+
// true barge-in): handleInterruption gates its handler on responseActive, but the
|
|
816
|
+
// turn boundary is real regardless of whether the model was mid-response.
|
|
817
|
+
this.userTurnTranscribed = false;
|
|
396
818
|
return this.handleInterruption();
|
|
397
819
|
case 'response.created':
|
|
820
|
+
// The model has taken the floor — the user's turn is definitively over. Clear the
|
|
821
|
+
// continuation anchor so a LATER utterance can never be merged into it just because it
|
|
822
|
+
// happens to start with the same words.
|
|
823
|
+
this.lastUserTranscript = '';
|
|
398
824
|
// A response is in flight (whether server-VAD-triggered or locally triggered).
|
|
399
825
|
this.responseActive = true;
|
|
400
826
|
return;
|
|
@@ -403,6 +829,55 @@ export class OpenAIRealtimeSession {
|
|
|
403
829
|
this.responseActive = false;
|
|
404
830
|
return this.handleResponseDone(event.response.usage);
|
|
405
831
|
default:
|
|
832
|
+
return this.dispatchMcpEvent(event);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* Handles the MCP slice of the server-event stream. MCP tool calls execute SERVER-SIDE at the
|
|
837
|
+
* provider (no MJ round-trip like function tools), so most lifecycle frames are diag-only. The
|
|
838
|
+
* one that needs action — `mcp_approval_request` — cannot be satisfied yet (no approval UX in
|
|
839
|
+
* the agent layer), so it is surfaced as a RECOVERABLE session error instead of silently
|
|
840
|
+
* stalling the session; config authors should declare MCP servers with `require_approval: 'never'`.
|
|
841
|
+
*
|
|
842
|
+
* The frames are matched by type string because the pinned SDK's `RealtimeServerEvent` union
|
|
843
|
+
* carries them with dedicated interfaces already (`McpListToolsFailed`, `ResponseMcpCallFailed`, etc.).
|
|
844
|
+
*
|
|
845
|
+
* @param event The (possibly MCP-related) server event.
|
|
846
|
+
*/
|
|
847
|
+
dispatchMcpEvent(event) {
|
|
848
|
+
switch (event.type) {
|
|
849
|
+
case 'response.mcp_call.failed':
|
|
850
|
+
RealtimeDiagLog(`[${this.profile.providerKey}Realtime][diag] MCP tool call FAILED`);
|
|
851
|
+
this.errorHandler?.({ Message: 'A remote MCP tool call failed at the provider', Fatal: false });
|
|
852
|
+
return;
|
|
853
|
+
case 'mcp_list_tools.failed':
|
|
854
|
+
RealtimeDiagLog(`[${this.profile.providerKey}Realtime][diag] MCP server tool listing FAILED`);
|
|
855
|
+
this.errorHandler?.({ Message: 'Listing tools from a remote MCP server failed at the provider', Fatal: false });
|
|
856
|
+
return;
|
|
857
|
+
default:
|
|
858
|
+
// mcp_approval_request arrives as a conversation item add — detect it structurally.
|
|
859
|
+
if (event.type === 'conversation.item.added' && event.item?.type === 'mcp_approval_request') {
|
|
860
|
+
// DEFENSIVE AUTO-DENY: no approval UX exists yet, and the model BLOCKS forever
|
|
861
|
+
// awaiting an mcp_approval_response — dead air from the user's perspective. A
|
|
862
|
+
// denial lets the model continue and voice the refusal instead of wedging the
|
|
863
|
+
// turn. Config authors who want silent MCP flow declare require_approval:'never'.
|
|
864
|
+
const approvalRequestId = event.item.id;
|
|
865
|
+
if (approvalRequestId) {
|
|
866
|
+
this.connection.send({
|
|
867
|
+
type: 'conversation.item.create',
|
|
868
|
+
item: {
|
|
869
|
+
type: 'mcp_approval_response',
|
|
870
|
+
approval_request_id: approvalRequestId,
|
|
871
|
+
approve: false,
|
|
872
|
+
},
|
|
873
|
+
});
|
|
874
|
+
RealtimeDiagLog(`[${this.profile.providerKey}Realtime][diag] MCP approval request AUTO-DENIED (no approval UX yet) — request ${approvalRequestId}`);
|
|
875
|
+
}
|
|
876
|
+
this.errorHandler?.({
|
|
877
|
+
Message: "An MCP server requested tool approval; no approval UX exists yet, so it was automatically DENIED (the model continues and voices the refusal). Declare the server with require_approval: 'never' to avoid the round-trip.",
|
|
878
|
+
Fatal: false,
|
|
879
|
+
});
|
|
880
|
+
}
|
|
406
881
|
return;
|
|
407
882
|
}
|
|
408
883
|
}
|
|
@@ -410,9 +885,18 @@ export class OpenAIRealtimeSession {
|
|
|
410
885
|
handleAudioDelta(deltaBase64) {
|
|
411
886
|
this.outputHandler?.(this.decodeBase64(deltaBase64));
|
|
412
887
|
}
|
|
413
|
-
/**
|
|
414
|
-
|
|
415
|
-
|
|
888
|
+
/**
|
|
889
|
+
* Emits a transcript event, skipping empty/whitespace text — empty captions are pure noise.
|
|
890
|
+
*
|
|
891
|
+
* @param replacesPrevious When true, this final REPLACES the current turn's persisted row in
|
|
892
|
+
* place (streamed-transcription providers whose repeated completeds carry the full growing
|
|
893
|
+
* text) rather than appending a new turn. Defaults to false (append/normal final).
|
|
894
|
+
*/
|
|
895
|
+
emitTranscript(role, text, isFinal, replacesPrevious = false) {
|
|
896
|
+
if (!text || text.trim().length === 0) {
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
this.transcriptHandler?.({ Role: role, Text: text, IsFinal: isFinal, ReplacesPrevious: replacesPrevious });
|
|
416
900
|
}
|
|
417
901
|
/** Forwards a completed function call to the tool-call handler. */
|
|
418
902
|
handleFunctionCall(callId, name, args) {
|
|
@@ -441,6 +925,9 @@ export class OpenAIRealtimeSession {
|
|
|
441
925
|
*/
|
|
442
926
|
handleConnectionError(error) {
|
|
443
927
|
const isProviderFrame = error.error != null;
|
|
928
|
+
if (!isProviderFrame) {
|
|
929
|
+
this.failConfigWait(error.message);
|
|
930
|
+
}
|
|
444
931
|
this.errorHandler?.({
|
|
445
932
|
Message: error.message,
|
|
446
933
|
Code: error.error?.code ?? undefined,
|
|
@@ -456,53 +943,62 @@ export class OpenAIRealtimeSession {
|
|
|
456
943
|
if (this.closedByConsumer) {
|
|
457
944
|
return;
|
|
458
945
|
}
|
|
459
|
-
this.
|
|
946
|
+
this.failConfigWait(this.profile.unexpectedCloseMessage);
|
|
947
|
+
this.errorHandler?.({ Message: this.profile.unexpectedCloseMessage, Fatal: true });
|
|
460
948
|
this.closeHandler?.();
|
|
461
949
|
}
|
|
462
|
-
/**
|
|
950
|
+
/**
|
|
951
|
+
* Translates a response's usage block into a {@link RealtimeUsage} update, INCLUDING the
|
|
952
|
+
* per-modality token details the GA API reports — realtime cost attribution is impossible
|
|
953
|
+
* without the audio/text/cached split (audio-in bills ~8x text-in on GPT Realtime 2.1).
|
|
954
|
+
*/
|
|
463
955
|
handleResponseDone(usage) {
|
|
464
956
|
if (!usage) {
|
|
465
957
|
return;
|
|
466
958
|
}
|
|
467
|
-
|
|
959
|
+
const update = {
|
|
468
960
|
InputTokens: usage.input_tokens ?? 0,
|
|
469
961
|
OutputTokens: usage.output_tokens ?? 0,
|
|
470
|
-
}
|
|
962
|
+
};
|
|
963
|
+
const input = MapUsageModalityDetail(usage.input_token_details);
|
|
964
|
+
if (input) {
|
|
965
|
+
update.InputTokenDetails = input;
|
|
966
|
+
}
|
|
967
|
+
const output = MapUsageModalityDetail(usage.output_token_details);
|
|
968
|
+
if (output) {
|
|
969
|
+
update.OutputTokenDetails = output;
|
|
970
|
+
}
|
|
971
|
+
this.usageHandler?.(update);
|
|
471
972
|
}
|
|
472
973
|
// ---- Config helpers ----
|
|
473
|
-
/** Sends the `session.update` that establishes instructions, input transcription, and
|
|
974
|
+
/** Sends the `session.update` that establishes instructions, input transcription, tools, and GA features. */
|
|
474
975
|
sendSessionUpdate(systemPrompt, tools, config) {
|
|
475
|
-
// Pull the
|
|
476
|
-
//
|
|
477
|
-
//
|
|
478
|
-
//
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
//
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
976
|
+
// Pull the MJ-idiomatic feature keys OUT of the open Config bag: the host-neutral meeting flag
|
|
977
|
+
// (disableAutoResponse), the output voice, and the GA features (reasoningEffort/parallelToolCalls/
|
|
978
|
+
// mcpTools) — each translated to its provider-native field only when the profile confirms support,
|
|
979
|
+
// and never sent raw to the API. In a multi-agent meeting the BRIDGE (after its turn policy gates
|
|
980
|
+
// on addressing), not the model, decides WHEN to speak — so we disable server-VAD auto-response
|
|
981
|
+
// while KEEPING detection so input transcription and barge-in still work. A 1:1 call (flag absent)
|
|
982
|
+
// keeps the provider's default auto-response.
|
|
983
|
+
const features = ExtractRealtimeFeatures(config);
|
|
984
|
+
const turnDetection = this.profile.buildTurnDetection(features.disableAutoResponse);
|
|
985
|
+
// Opt into USER input transcription — the same opt-in CreateClientSession applies for the
|
|
986
|
+
// client-direct topology — so user-role transcripts flow server-bridged too (the contract
|
|
987
|
+
// promises BOTH roles). Providers that transcribe natively (profile model undefined, no bag
|
|
988
|
+
// override) get no transcription block; an all-empty audio block is omitted entirely. The
|
|
989
|
+
// residual config bag spreads AFTER the built block so a per-conversation raw `audio`
|
|
990
|
+
// override can still replace it wholesale.
|
|
991
|
+
const audio = BuildAudioBlock(this.profile, features, turnDetection);
|
|
490
992
|
const session = {
|
|
491
993
|
type: 'realtime',
|
|
492
994
|
instructions: systemPrompt,
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
// contract promises BOTH roles). The config bag spreads after this so a
|
|
496
|
-
// per-conversation override can still replace the audio block.
|
|
497
|
-
audio: {
|
|
498
|
-
input: { transcription: { model: OPENAI_INPUT_TRANSCRIPTION_MODEL }, ...(turnDetection ? { turn_detection: turnDetection } : {}) },
|
|
499
|
-
...(voice ? { output: { voice } } : {}),
|
|
500
|
-
},
|
|
501
|
-
...cfg,
|
|
995
|
+
...(audio ? { audio } : {}),
|
|
996
|
+
...features.rest,
|
|
502
997
|
};
|
|
503
998
|
if (tools && tools.length > 0) {
|
|
504
999
|
session.tools = this.mapTools(tools);
|
|
505
1000
|
}
|
|
1001
|
+
applyGAFeatures(session, features, this.profile);
|
|
506
1002
|
this.connection.send({ type: 'session.update', session });
|
|
507
1003
|
}
|
|
508
1004
|
/** Seeds the conversation with initial context as a user text message. */
|