@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.
@@ -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
- constructor(apiKey) {
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 = new OpenAIRealtimeSession(connection);
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 OpenAI's Realtime client-secrets API. Overridable
112
- * seam for testing unit tests return a fake response so no network call is made.
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 OpenAI client-secret create response (token value + expiry + echoed session).
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
- // Enable transcription of the user's mic input so BOTH sides of the conversation are
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: 'openai',
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
- constructor(connection) {
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
- * **Deferred to `session.created`.** When `StartSession` returns, the realtime WebSocket is NOT open yet
199
- * — sending `session.update` synchronously races the handshake and the instructions (the **system
200
- * prompt + tools**) are silently dropped, so the model runs with NO prompt (no identity, no companion
201
- * framing). We therefore wait for the server's `session.created` frame — the first event once the socket
202
- * is open and the session exists, and the canonical moment to configure a realtime session — exactly the
203
- * point the browser/client-direct path applies its config. Idempotent (a re-emitted `session.created`
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.sendSessionUpdate(params.SystemPrompt, params.Tools, params.Config);
217
- if (params.InitialContext && params.InitialContext.length > 0) {
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('[OpenAIRealtime][diag] RequestSpokenUpdate SKIPPED — a response is already active (interim updates are disposable)');
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(`[OpenAIRealtime][diag] RequestSpokenUpdate → sending response.create (perResponseInstructions=${typeof instructions === 'string' && instructions.trim().length > 0 ? 'yes' : 'none → session prompt governs'})`);
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 — OpenAI's `session.update` is runtime-mutable, so a live turn-mode change is supported. */
699
+ /** @inheritdoc — profile-gated: only providers whose endpoint honors a live partial `session.update`. */
313
700
  get Capabilities() {
314
- return { CanReconfigureTurnMode: true };
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: { model: OPENAI_INPUT_TRANSCRIPTION_MODEL }, turn_detection: turnDetection } },
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
- return this.emitTranscript('user', event.transcript, true);
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
- /** Emits a transcript event to the transcript handler. */
414
- emitTranscript(role, text, isFinal) {
415
- this.transcriptHandler?.({ Role: role, Text: text, IsFinal: isFinal });
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.errorHandler?.({ Message: 'OpenAI realtime connection closed unexpectedly', Fatal: true });
946
+ this.failConfigWait(this.profile.unexpectedCloseMessage);
947
+ this.errorHandler?.({ Message: this.profile.unexpectedCloseMessage, Fatal: true });
460
948
  this.closeHandler?.();
461
949
  }
462
- /** Translates a response's usage block into a {@link RealtimeUsage} update. */
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
- this.usageHandler?.({
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 tools. */
974
+ /** Sends the `session.update` that establishes instructions, input transcription, tools, and GA features. */
474
975
  sendSessionUpdate(systemPrompt, tools, config) {
475
- // Pull the host-neutral meeting flag OUT of the open Config bag so it is never sent raw to the API.
476
- // In a multi-agent meeting the BRIDGE (after its turn policy gates on addressing), not the model,
477
- // decides WHEN to speak so we disable server-VAD auto-response while KEEPING detection so input
478
- // transcription and barge-in still work. A 1:1 call (flag absent) keeps the default auto-response.
479
- const cfg = { ...(config ?? {}) };
480
- const disableAutoResponse = cfg.disableAutoResponse === true;
481
- delete cfg.disableAutoResponse;
482
- // Pull the OUTPUT voice out of the bag too — OpenAI's realtime session takes it at
483
- // `audio.output.voice` (NOT top-level), so letting it spread via `...cfg` would silently no-op (the
484
- // co-agent's configured voice / a per-session dev override would be ignored on the server-bridged path).
485
- const voice = typeof cfg.voice === 'string' ? cfg.voice.trim() : '';
486
- delete cfg.voice;
487
- const turnDetection = disableAutoResponse
488
- ? { type: 'server_vad', create_response: false, interrupt_response: true }
489
- : undefined;
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
- // Opt into USER input transcription — the same opt-in CreateClientSession applies for
494
- // the client-direct topology — so user-role transcripts flow server-bridged too (the
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. */