@kuralle-syrinx/realtime 4.2.0 → 4.4.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/README.md CHANGED
@@ -105,6 +105,34 @@ Cartesia, …) all dial through `createWorkersSocket` so auth headers ride on th
105
105
  Regression lock: `edge-safety.test.ts` runs the adapter + bridge with `Buffer` and `process` removed from
106
106
  `globalThis`.
107
107
 
108
+ ### Gemini Live transcription, voice, and API version
109
+
110
+ `fromGeminiLive` keeps both transcription directions enabled by default and makes each one
111
+ configurable (set either to `false` to disable it):
112
+
113
+ ```ts
114
+ const adapter = fromGeminiLive({
115
+ apiKey,
116
+ transcription: { input: true, output: true },
117
+ speechConfig: { voice: "Kore", languageCode: "en-US" },
118
+ apiVersion: "v1alpha",
119
+ });
120
+ ```
121
+
122
+ The adapter maps these options to Gemini Live's `inputAudioTranscription`,
123
+ `outputAudioTranscription`, and `speechConfig.voiceConfig.prebuiltVoiceConfig.voiceName` setup fields.
124
+ The server reports both transcripts in `serverContent.inputTranscription` and
125
+ `serverContent.outputTranscription`, which become `{ type: "transcript", role, text, final }` events.
126
+
127
+ Google's [Live API capabilities guide](https://ai.google.dev/gemini-api/docs/live-api/capabilities)
128
+ currently documents input transcription as unsupported by Gemini 3.1 Flash Live Preview. When using
129
+ that model, enabling `transcription.input` will not produce user transcripts; use a Live model that
130
+ supports input transcription or a separate STT provider when user transcripts are required. Native
131
+ audio models also choose their output language automatically, so `languageCode` is intended for models
132
+ that support explicit language selection. See Google's [API version guide](https://ai.google.dev/gemini-api/docs/api-versions)
133
+ for version semantics; `apiVersion` is applied at SDK client level because Live does not support
134
+ request-level HTTP options.
135
+
108
136
  ## The Responder-Thinker primitive (the delegate seam)
109
137
 
110
138
  The bi-model shape has a name: **Responder-Thinker** — a fast realtime **responder** on the audio
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kuralle-syrinx/realtime",
3
- "version": "4.2.0",
3
+ "version": "4.4.0",
4
4
  "private": false,
5
5
  "description": "Realtime speech-to-speech bridge for Syrinx — OpenAI Realtime, Gemini Live, and openai-compatible adapters; the Responder-Thinker delegate seam",
6
6
  "keywords": [
@@ -28,12 +28,12 @@
28
28
  "types": "./src/index.ts",
29
29
  "dependencies": {
30
30
  "@google/genai": "^2.8.0",
31
- "@kuralle-syrinx/core": "4.2.0",
32
- "@kuralle-syrinx/ws": "4.2.0"
31
+ "@kuralle-syrinx/ws": "4.4.0",
32
+ "@kuralle-syrinx/core": "4.4.0"
33
33
  },
34
34
  "devDependencies": {
35
35
  "typescript": "^5.7.0",
36
- "vitest": "^2.1.0"
36
+ "vitest": "^3.2.6"
37
37
  },
38
38
  "scripts": {
39
39
  "typecheck": "tsc --noEmit",
@@ -10,11 +10,35 @@ const DEFAULT_MODEL = "gemini-3.1-flash-live-preview";
10
10
  const INPUT_SAMPLE_RATE_HZ = 16_000;
11
11
  const OUTPUT_SAMPLE_RATE_HZ = 24_000;
12
12
 
13
+ export interface GeminiLiveTranscriptionOptions {
14
+ /**
15
+ * Enable input transcription, or provide Gemini's AudioTranscriptionConfig. Enabled by default.
16
+ *
17
+ * Default stays ON because `RealtimeBridge` turns `role: "user"` transcripts into `stt.result`
18
+ * packets — defaulting this off would silently remove all user-side text on the Gemini front.
19
+ * Issue #32 asked for it to be *configurable*, not disabled.
20
+ */
21
+ readonly input?: boolean | Record<string, unknown>;
22
+ /** Enable output transcription, or provide Gemini's AudioTranscriptionConfig. Enabled by default. */
23
+ readonly output?: boolean | Record<string, unknown>;
24
+ }
25
+
26
+ export interface GeminiLiveSpeechConfig {
27
+ /** Prebuilt Gemini voice name, mapped to speechConfig.voiceConfig.prebuiltVoiceConfig.voiceName. */
28
+ readonly voice?: string;
29
+ /** ISO language code for speech synthesis. */
30
+ readonly languageCode?: string;
31
+ }
32
+
13
33
  export interface GeminiLiveOptions {
14
34
  readonly apiKey: string;
15
35
  readonly model?: string;
16
36
  readonly systemInstruction?: string;
17
37
  readonly tools?: readonly RealtimeToolDef[];
38
+ readonly transcription?: GeminiLiveTranscriptionOptions;
39
+ readonly speechConfig?: GeminiLiveSpeechConfig;
40
+ /** Gemini Developer API version, e.g. `v1alpha` for preview-only Live features. */
41
+ readonly apiVersion?: string;
18
42
  /**
19
43
  * G4 native resume: a `sessionResumption` handle from a prior session's
20
44
  * `resumption_handle` events. Gemini restores the conversation server-side —
@@ -23,6 +47,34 @@ export interface GeminiLiveOptions {
23
47
  * prior handle back on reconnect.
24
48
  */
25
49
  readonly sessionResumptionHandle?: string;
50
+ /**
51
+ * Full `sessionResumption` LiveConnectConfig. Defaults to `{}` (handles issued)
52
+ * or `{ handle }` when `sessionResumptionHandle` is set. Pass `false` to omit.
53
+ */
54
+ readonly sessionResumption?: Record<string, unknown> | false;
55
+ /** Response modalities. Defaults to `["AUDIO"]` (previous hard-pin). */
56
+ readonly responseModalities?: readonly string[];
57
+ /** LiveConnectConfig `generationConfig` (subset supported by Live). */
58
+ readonly generationConfig?: Record<string, unknown>;
59
+ /** LiveConnectConfig `safetySettings`. */
60
+ readonly safetySettings?: readonly Record<string, unknown>[];
61
+ readonly temperature?: number;
62
+ readonly topP?: number;
63
+ readonly topK?: number;
64
+ readonly maxOutputTokens?: number;
65
+ readonly mediaResolution?: string;
66
+ readonly seed?: number;
67
+ readonly thinkingConfig?: Record<string, unknown>;
68
+ readonly enableAffectiveDialog?: boolean;
69
+ readonly realtimeInputConfig?: Record<string, unknown>;
70
+ readonly contextWindowCompression?: Record<string, unknown>;
71
+ readonly proactivity?: Record<string, unknown>;
72
+ readonly explicitVadSignal?: boolean;
73
+ /**
74
+ * Merged last into `live.connect` config for any LiveConnectConfig field the
75
+ * adapter does not enumerate (avatarConfig, …). Overrides same-key defaults.
76
+ */
77
+ readonly connectConfig?: Record<string, unknown>;
26
78
  }
27
79
 
28
80
  class GeminiLiveAdapter implements RealtimeAdapter {
@@ -61,29 +113,106 @@ class GeminiLiveAdapter implements RealtimeAdapter {
61
113
  }],
62
114
  }));
63
115
 
116
+ const transcription = this.opts.transcription;
64
117
  const config: Record<string, unknown> = {
65
- responseModalities: [Modality.AUDIO],
66
- inputAudioTranscription: {},
67
- outputAudioTranscription: {},
68
- // G4: always on so the server issues resumption handles; a prior handle
69
- // resumes the conversation server-side (native resume — no replay).
70
- sessionResumption: this.opts.sessionResumptionHandle
71
- ? { handle: this.opts.sessionResumptionHandle }
72
- : {},
118
+ // Default preserves the prior hard-pin of AUDIO-only responses.
119
+ responseModalities: this.opts.responseModalities
120
+ ? [...this.opts.responseModalities]
121
+ : [Modality.AUDIO],
73
122
  };
123
+ // G4: session resumption defaults ON so the server issues handles; a prior
124
+ // handle resumes server-side (native resume — no replay). Override via
125
+ // sessionResumption (full object) or disable with false.
126
+ if (!("sessionResumption" in this.opts) || this.opts.sessionResumption !== false) {
127
+ const base =
128
+ typeof this.opts.sessionResumption === "object" && this.opts.sessionResumption !== null
129
+ ? { ...this.opts.sessionResumption }
130
+ : {};
131
+ if (this.opts.sessionResumptionHandle !== undefined) {
132
+ base["handle"] = this.opts.sessionResumptionHandle;
133
+ }
134
+ config["sessionResumption"] = base;
135
+ }
136
+ const inputTranscription = transcription?.input ?? true;
137
+ const outputTranscription = transcription?.output ?? true;
138
+ if (inputTranscription !== false) {
139
+ config["inputAudioTranscription"] = inputTranscription === true ? {} : inputTranscription;
140
+ }
141
+ if (outputTranscription !== false) {
142
+ config["outputAudioTranscription"] = outputTranscription === true ? {} : outputTranscription;
143
+ }
144
+ const speechConfig = this.opts.speechConfig;
145
+ if (speechConfig && (speechConfig.voice !== undefined || speechConfig.languageCode !== undefined)) {
146
+ config["speechConfig"] = {
147
+ ...(speechConfig.voice === undefined
148
+ ? {}
149
+ : { voiceConfig: { prebuiltVoiceConfig: { voiceName: speechConfig.voice } } }),
150
+ ...(speechConfig.languageCode === undefined ? {} : { languageCode: speechConfig.languageCode }),
151
+ };
152
+ }
74
153
  if (this.opts.systemInstruction) {
75
154
  config["systemInstruction"] = this.opts.systemInstruction;
76
155
  }
77
156
  if (tools.length > 0) {
78
157
  config["tools"] = tools;
79
158
  }
159
+ if (this.opts.generationConfig !== undefined) {
160
+ config["generationConfig"] = this.opts.generationConfig;
161
+ }
162
+ if (this.opts.safetySettings !== undefined) {
163
+ config["safetySettings"] = this.opts.safetySettings;
164
+ }
165
+ if (this.opts.temperature !== undefined) {
166
+ config["temperature"] = this.opts.temperature;
167
+ }
168
+ if (this.opts.topP !== undefined) {
169
+ config["topP"] = this.opts.topP;
170
+ }
171
+ if (this.opts.topK !== undefined) {
172
+ config["topK"] = this.opts.topK;
173
+ }
174
+ if (this.opts.maxOutputTokens !== undefined) {
175
+ config["maxOutputTokens"] = this.opts.maxOutputTokens;
176
+ }
177
+ if (this.opts.mediaResolution !== undefined) {
178
+ config["mediaResolution"] = this.opts.mediaResolution;
179
+ }
180
+ if (this.opts.seed !== undefined) {
181
+ config["seed"] = this.opts.seed;
182
+ }
183
+ if (this.opts.thinkingConfig !== undefined) {
184
+ config["thinkingConfig"] = this.opts.thinkingConfig;
185
+ }
186
+ if (this.opts.enableAffectiveDialog !== undefined) {
187
+ config["enableAffectiveDialog"] = this.opts.enableAffectiveDialog;
188
+ }
189
+ if (this.opts.realtimeInputConfig !== undefined) {
190
+ config["realtimeInputConfig"] = this.opts.realtimeInputConfig;
191
+ }
192
+ if (this.opts.contextWindowCompression !== undefined) {
193
+ config["contextWindowCompression"] = this.opts.contextWindowCompression;
194
+ }
195
+ if (this.opts.proactivity !== undefined) {
196
+ config["proactivity"] = this.opts.proactivity;
197
+ }
198
+ if (this.opts.explicitVadSignal !== undefined) {
199
+ config["explicitVadSignal"] = this.opts.explicitVadSignal;
200
+ }
201
+ if (this.opts.connectConfig) {
202
+ Object.assign(config, this.opts.connectConfig);
203
+ }
80
204
 
81
205
  const openPromise = new Promise<void>((resolve, reject) => {
82
206
  this.openResolver = resolve;
83
207
  this.openRejecter = reject;
84
208
  });
85
209
 
86
- const ai = new GoogleGenAI({ apiKey: this.opts.apiKey });
210
+ const ai = new GoogleGenAI({
211
+ apiKey: this.opts.apiKey,
212
+ ...(this.opts.apiVersion === undefined
213
+ ? {}
214
+ : { httpOptions: { apiVersion: this.opts.apiVersion } }),
215
+ });
87
216
 
88
217
  this.session = await ai.live.connect({
89
218
  model,
@@ -125,6 +254,15 @@ class GeminiLiveAdapter implements RealtimeAdapter {
125
254
  });
126
255
  }
127
256
 
257
+ injectContext(text: string): void {
258
+ // Gemini Live drops system/developer roles from conversation history. A silent,
259
+ // incomplete user turn preserves the steering context without requesting a response.
260
+ this.requireSession().sendClientContent({
261
+ turns: [{ role: "user", parts: [{ text: `[Context-only instruction]\n${text}` }] }],
262
+ turnComplete: false,
263
+ });
264
+ }
265
+
128
266
  cancelResponse(_audioEndMs: number): void {
129
267
  // Gemini handles interruption server-side via `interrupted`; no truncate API.
130
268
  }
@@ -33,6 +33,19 @@ export interface OpenAIRealtimeOptions {
33
33
  readonly toolChoice?: string | Record<string, unknown>;
34
34
  readonly inputRateHz?: number;
35
35
  readonly outputRateHz?: number;
36
+ /** Input audio format object. Defaults to `{ type: "audio/pcm", rate: inputRateHz }`. */
37
+ readonly inputAudioFormat?: Record<string, unknown>;
38
+ /** Output audio format object. Defaults to `{ type: "audio/pcm", rate: outputRateHz }`. */
39
+ readonly outputAudioFormat?: Record<string, unknown>;
40
+ /** OpenAI Realtime `audio.input.noise_reduction` (e.g. `{ type: "near_field" }`). */
41
+ readonly noiseReduction?: Record<string, unknown> | null;
42
+ /** OpenAI Realtime `max_output_tokens` session field. */
43
+ readonly maxOutputTokens?: number | "inf";
44
+ /**
45
+ * Merged last into `session.update.session` for any provider field the adapter does not enumerate
46
+ * (speed, tracing, prompt, include, …). Overrides same-key defaults when both set.
47
+ */
48
+ readonly sessionConfig?: Record<string, unknown>;
36
49
  /**
37
50
  * G4 resume-by-replay: returns the prior conversation to replay as
38
51
  * `conversation.item.create` items on every (re)connect — OpenAI Realtime has no
@@ -64,7 +77,7 @@ export function fromOpenAIRealtime(opts: OpenAIRealtimeOptions): RealtimeAdapter
64
77
  },
65
78
  buildSessionUpdate: () => {
66
79
  const inputAudio: Record<string, unknown> = {
67
- format: { type: "audio/pcm", rate: inputRateHz },
80
+ format: opts.inputAudioFormat ?? { type: "audio/pcm", rate: inputRateHz },
68
81
  turn_detection:
69
82
  "turnDetection" in opts ? opts.turnDetection : { type: "semantic_vad" },
70
83
  };
@@ -72,6 +85,14 @@ export function fromOpenAIRealtime(opts: OpenAIRealtimeOptions): RealtimeAdapter
72
85
  inputAudio["transcription"] =
73
86
  opts.inputTranscription === true ? { model: "whisper-1" } : opts.inputTranscription;
74
87
  }
88
+ if (opts.noiseReduction !== undefined) {
89
+ inputAudio["noise_reduction"] = opts.noiseReduction;
90
+ }
91
+
92
+ const outputAudio: Record<string, unknown> = {
93
+ format: opts.outputAudioFormat ?? { type: "audio/pcm", rate: outputRateHz },
94
+ voice,
95
+ };
75
96
 
76
97
  const session: Record<string, unknown> = {
77
98
  type: "realtime",
@@ -79,10 +100,7 @@ export function fromOpenAIRealtime(opts: OpenAIRealtimeOptions): RealtimeAdapter
79
100
  output_modalities: opts.modalities ?? ["audio"],
80
101
  audio: {
81
102
  input: inputAudio,
82
- output: {
83
- format: { type: "audio/pcm", rate: outputRateHz },
84
- voice,
85
- },
103
+ output: outputAudio,
86
104
  },
87
105
  tools: (opts.tools ?? []).map((t) => ({
88
106
  type: "function" as const,
@@ -99,6 +117,12 @@ export function fromOpenAIRealtime(opts: OpenAIRealtimeOptions): RealtimeAdapter
99
117
  if (opts.temperature !== undefined) {
100
118
  session["temperature"] = opts.temperature;
101
119
  }
120
+ if (opts.maxOutputTokens !== undefined) {
121
+ session["max_output_tokens"] = opts.maxOutputTokens;
122
+ }
123
+ if (opts.sessionConfig) {
124
+ Object.assign(session, opts.sessionConfig);
125
+ }
102
126
 
103
127
  return session;
104
128
  },
package/src/index.ts CHANGED
@@ -12,7 +12,12 @@ export {
12
12
  type OpenAiCompatibleRealtimeConfig,
13
13
  } from "./openai-compatible-realtime.js";
14
14
  export { fromOpenAIRealtime, type OpenAIRealtimeOptions } from "./from-openai-realtime.js";
15
- export { fromGeminiLive, type GeminiLiveOptions } from "./from-gemini-live.js";
15
+ export {
16
+ fromGeminiLive,
17
+ type GeminiLiveOptions,
18
+ type GeminiLiveSpeechConfig,
19
+ type GeminiLiveTranscriptionOptions,
20
+ } from "./from-gemini-live.js";
16
21
  export {
17
22
  createGeminiTranslateSession,
18
23
  GEMINI_TRANSLATE_MODEL,
@@ -5,7 +5,7 @@ import { RealtimeSocket } from "@kuralle-syrinx/ws/realtime";
5
5
 
6
6
  import { base64ToBytes, bytesToBase64 } from "./base64.js";
7
7
  import { RealtimeEventStream } from "./realtime-event-stream.js";
8
- import type { RealtimeAdapter, RealtimeEvent, RealtimeResumeMessage } from "./realtime-adapter.js";
8
+ import type { RealtimeAdapter, RealtimeEvent, RealtimeResumeMessage, RealtimeUsage } from "./realtime-adapter.js";
9
9
 
10
10
  export interface OpenAiCompatibleRealtimeConfig {
11
11
  readonly apiKey: string;
@@ -18,6 +18,11 @@ export interface OpenAiCompatibleRealtimeConfig {
18
18
  readonly buildUrl?: (model: string) => string;
19
19
  readonly caps: RealtimeAdapter["caps"];
20
20
  readonly buildSessionUpdate: () => Record<string, unknown>;
21
+ /**
22
+ * Optional fields merged into `session.update.session` after `buildSessionUpdate()`.
23
+ * Use for provider-specific knobs the adapter does not enumerate (mirrors TTS `extra_body`).
24
+ */
25
+ readonly sessionExtra?: Record<string, unknown> | (() => Record<string, unknown>);
21
26
  /**
22
27
  * G4 resume-by-replay: returns the prior conversation to re-create as
23
28
  * `conversation.item.create` items after each (re)connect's `session.update` —
@@ -117,9 +122,14 @@ class OpenAiCompatibleRealtimeAdapter implements RealtimeAdapter {
117
122
  private applySessionConfig(): void {
118
123
  const reconnected = this.opened;
119
124
  this.opened = true;
125
+ const session = this.config.buildSessionUpdate();
126
+ const extra =
127
+ typeof this.config.sessionExtra === "function"
128
+ ? this.config.sessionExtra()
129
+ : this.config.sessionExtra;
120
130
  this.socket?.send({
121
131
  type: "session.update",
122
- session: this.config.buildSessionUpdate(),
132
+ session: extra ? { ...session, ...extra } : session,
123
133
  });
124
134
  // G4 resume-by-replay: re-create the prior conversation as items. Providers
125
135
  // without native resume start every (re)connection amnesiac; replaying the
@@ -171,6 +181,17 @@ class OpenAiCompatibleRealtimeAdapter implements RealtimeAdapter {
171
181
  this.requestResponseCreate();
172
182
  }
173
183
 
184
+ injectContext(text: string): void {
185
+ this.requireSocket().send({
186
+ type: "conversation.item.create",
187
+ item: {
188
+ type: "message",
189
+ role: "system",
190
+ content: [{ type: "input_text", text }],
191
+ },
192
+ });
193
+ }
194
+
174
195
  requestResponse(): void {
175
196
  this.requireSocket().send({ type: "input_audio_buffer.commit" });
176
197
  this.requestResponseCreate();
@@ -311,6 +332,9 @@ class OpenAiCompatibleRealtimeAdapter implements RealtimeAdapter {
311
332
  case "input_audio_buffer.speech_started":
312
333
  this.stream.push({ type: "speech_started" });
313
334
  break;
335
+ case "input_audio_buffer.speech_stopped":
336
+ this.stream.push({ type: "speech_stopped" });
337
+ break;
314
338
  case "response.output_audio_transcript.delta": {
315
339
  const delta = msg["delta"];
316
340
  if (typeof delta === "string" && delta.length > 0) {
@@ -374,7 +398,10 @@ class OpenAiCompatibleRealtimeAdapter implements RealtimeAdapter {
374
398
  if (toolCall) {
375
399
  this.stream.push(toolCall);
376
400
  }
377
- this.stream.push({ type: "response_done" });
401
+ // response.usage is snake_case token counts; forward so the bridge can meter
402
+ // the native front (previously native turns produced NO usage at all).
403
+ const usage = extractRealtimeUsage(msg["response"]);
404
+ this.stream.push(usage ? { type: "response_done", usage } : { type: "response_done" });
378
405
  break;
379
406
  }
380
407
  case "error": {
@@ -385,6 +412,14 @@ class OpenAiCompatibleRealtimeAdapter implements RealtimeAdapter {
385
412
  : this.config.defaultErrorMessage;
386
413
  const code =
387
414
  errObj && typeof errObj === "object" ? (errObj as Record<string, unknown>)["code"] : undefined;
415
+ // A `response.cancel` that raced a just-completed response ("no active response found")
416
+ // is benign — the cancel is a server-side no-op. This is the multi-turn barge-in cancel
417
+ // race (a new turn's speech_started fires a cancel as the prior response completes). Do
418
+ // NOT surface it: it would trip strict consumers and pollute error-rate monitoring.
419
+ const codeStr = typeof code === "string" ? code : "";
420
+ if (codeStr === "response_cancel_not_active" || /no active response|cancellation failed/i.test(message)) {
421
+ break;
422
+ }
388
423
  const recoverable = code !== "invalid_api_key" && code !== "authentication_failed";
389
424
  this.stream.push({
390
425
  type: "error",
@@ -399,6 +434,21 @@ class OpenAiCompatibleRealtimeAdapter implements RealtimeAdapter {
399
434
  }
400
435
  }
401
436
 
437
+ /** Pull snake_case token counts off a realtime response.usage object, when present. */
438
+ function extractRealtimeUsage(response: unknown): RealtimeUsage | undefined {
439
+ if (!response || typeof response !== "object") return undefined;
440
+ const usage = (response as Record<string, unknown>)["usage"];
441
+ if (!usage || typeof usage !== "object") return undefined;
442
+ const u = usage as Record<string, unknown>;
443
+ const num = (v: unknown): number | undefined => (typeof v === "number" ? v : undefined);
444
+ const out: RealtimeUsage = {
445
+ ...(num(u["input_tokens"]) !== undefined ? { inputTokens: num(u["input_tokens"]) } : {}),
446
+ ...(num(u["output_tokens"]) !== undefined ? { outputTokens: num(u["output_tokens"]) } : {}),
447
+ ...(num(u["total_tokens"]) !== undefined ? { totalTokens: num(u["total_tokens"]) } : {}),
448
+ };
449
+ return Object.keys(out).length > 0 ? out : undefined;
450
+ }
451
+
402
452
  function extractFunctionCall(response: unknown): RealtimeEvent | null {
403
453
  if (!response || typeof response !== "object") return null;
404
454
  const output = (response as Record<string, unknown>)["output"];
@@ -33,6 +33,8 @@ export interface RealtimeAdapter {
33
33
  * provider cannot accept text input omit it, and the bridge silently ignores typed turns for them.
34
34
  */
35
35
  sendText?(text: string): void;
36
+ /** Inject transient context without requesting a provider response. */
37
+ injectContext?(text: string): void;
36
38
  /**
37
39
  * Commit any buffered user input and request a response. For Syrinx-OWNED turn detection
38
40
  * (provider server VAD disabled via turnDetection:null): the host calls this when its own
@@ -64,13 +66,21 @@ export interface RealtimeResumeMessage {
64
66
  readonly content: string;
65
67
  }
66
68
 
69
+ /** Token usage a realtime provider reports on response completion (e.g. OpenAI response.done). */
70
+ export interface RealtimeUsage {
71
+ readonly inputTokens?: number;
72
+ readonly outputTokens?: number;
73
+ readonly totalTokens?: number;
74
+ }
75
+
67
76
  export type RealtimeEvent =
68
77
  | { type: "audio"; pcm16: Uint8Array; sampleRateHz: number }
69
78
  | { type: "speech_started" }
79
+ | { type: "speech_stopped" }
70
80
  | { type: "transcript"; role: "user" | "assistant"; text: string; final: boolean }
71
81
  | { type: "tool_call"; toolId: string; toolName: string; args: Record<string, unknown> }
72
82
  | { type: "response_started" }
73
- | { type: "response_done" }
83
+ | { type: "response_done"; usage?: RealtimeUsage }
74
84
  // G4: a native-resume provider issued a fresh resumption handle — persist the
75
85
  // latest one and pass it back on reconnect (Gemini `sessionResumption`).
76
86
  | { type: "resumption_handle"; handle: string }