@kuralle-syrinx/realtime 4.1.0 → 4.3.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,19 +1,39 @@
1
1
  {
2
2
  "name": "@kuralle-syrinx/realtime",
3
- "version": "4.1.0",
3
+ "version": "4.3.0",
4
4
  "private": false,
5
- "type": "module",
5
+ "description": "Realtime speech-to-speech bridge for Syrinx — OpenAI Realtime, Gemini Live, and openai-compatible adapters; the Responder-Thinker delegate seam",
6
+ "keywords": [
7
+ "voice",
8
+ "voice-agent",
9
+ "speech",
10
+ "syrinx",
11
+ "realtime",
12
+ "speech-to-speech",
13
+ "openai-realtime",
14
+ "gemini-live"
15
+ ],
6
16
  "license": "MIT",
17
+ "homepage": "https://github.com/kuralle/syrinx#readme",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/kuralle/syrinx.git",
21
+ "directory": "packages/realtime"
22
+ },
23
+ "bugs": {
24
+ "url": "https://github.com/kuralle/syrinx/issues"
25
+ },
26
+ "type": "module",
7
27
  "main": "./src/index.ts",
8
28
  "types": "./src/index.ts",
9
29
  "dependencies": {
10
30
  "@google/genai": "^2.8.0",
11
- "@kuralle-syrinx/core": "4.1.0",
12
- "@kuralle-syrinx/ws": "4.1.0"
31
+ "@kuralle-syrinx/ws": "4.3.0",
32
+ "@kuralle-syrinx/core": "4.3.0"
13
33
  },
14
34
  "devDependencies": {
15
35
  "typescript": "^5.7.0",
16
- "vitest": "^2.1.0"
36
+ "vitest": "^3.2.6"
17
37
  },
18
38
  "scripts": {
19
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
@@ -60,10 +73,11 @@ export function fromOpenAIRealtime(opts: OpenAIRealtimeOptions): RealtimeAdapter
60
73
  supportsConcurrentToolAudio: true,
61
74
  supportsTruncate: true,
62
75
  emitsServerSpeechStarted: true,
76
+ supportsTextOnlyModality: opts.modalities?.length === 1 && opts.modalities[0] === "text",
63
77
  },
64
78
  buildSessionUpdate: () => {
65
79
  const inputAudio: Record<string, unknown> = {
66
- format: { type: "audio/pcm", rate: inputRateHz },
80
+ format: opts.inputAudioFormat ?? { type: "audio/pcm", rate: inputRateHz },
67
81
  turn_detection:
68
82
  "turnDetection" in opts ? opts.turnDetection : { type: "semantic_vad" },
69
83
  };
@@ -71,6 +85,14 @@ export function fromOpenAIRealtime(opts: OpenAIRealtimeOptions): RealtimeAdapter
71
85
  inputAudio["transcription"] =
72
86
  opts.inputTranscription === true ? { model: "whisper-1" } : opts.inputTranscription;
73
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
+ };
74
96
 
75
97
  const session: Record<string, unknown> = {
76
98
  type: "realtime",
@@ -78,10 +100,7 @@ export function fromOpenAIRealtime(opts: OpenAIRealtimeOptions): RealtimeAdapter
78
100
  output_modalities: opts.modalities ?? ["audio"],
79
101
  audio: {
80
102
  input: inputAudio,
81
- output: {
82
- format: { type: "audio/pcm", rate: outputRateHz },
83
- voice,
84
- },
103
+ output: outputAudio,
85
104
  },
86
105
  tools: (opts.tools ?? []).map((t) => ({
87
106
  type: "function" as const,
@@ -98,6 +117,12 @@ export function fromOpenAIRealtime(opts: OpenAIRealtimeOptions): RealtimeAdapter
98
117
  if (opts.temperature !== undefined) {
99
118
  session["temperature"] = opts.temperature;
100
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
+ }
101
126
 
102
127
  return session;
103
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,22 @@ 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
+
195
+ requestResponse(): void {
196
+ this.requireSocket().send({ type: "input_audio_buffer.commit" });
197
+ this.requestResponseCreate();
198
+ }
199
+
174
200
  cancelResponse(audioEndMs: number): void {
175
201
  if (!this.activeResponse) return;
176
202
  const socket = this.requireSocket();
@@ -306,6 +332,9 @@ class OpenAiCompatibleRealtimeAdapter implements RealtimeAdapter {
306
332
  case "input_audio_buffer.speech_started":
307
333
  this.stream.push({ type: "speech_started" });
308
334
  break;
335
+ case "input_audio_buffer.speech_stopped":
336
+ this.stream.push({ type: "speech_stopped" });
337
+ break;
309
338
  case "response.output_audio_transcript.delta": {
310
339
  const delta = msg["delta"];
311
340
  if (typeof delta === "string" && delta.length > 0) {
@@ -331,6 +360,30 @@ class OpenAiCompatibleRealtimeAdapter implements RealtimeAdapter {
331
360
  this.assistantTranscript = "";
332
361
  break;
333
362
  }
363
+ case "response.output_text.delta": {
364
+ const delta = msg["delta"];
365
+ if (typeof delta === "string" && delta.length > 0) {
366
+ this.assistantTranscript += delta;
367
+ this.stream.push({
368
+ type: "transcript",
369
+ role: "assistant",
370
+ text: delta,
371
+ final: false,
372
+ });
373
+ }
374
+ break;
375
+ }
376
+ case "response.output_text.done": {
377
+ const t = typeof msg["text"] === "string" ? msg["text"] : this.assistantTranscript;
378
+ this.stream.push({
379
+ type: "transcript",
380
+ role: "assistant",
381
+ text: t,
382
+ final: true,
383
+ });
384
+ this.assistantTranscript = "";
385
+ break;
386
+ }
334
387
  case "conversation.item.input_audio_transcription.completed": {
335
388
  // User-side transcript (requires input transcription enabled in session config).
336
389
  const transcript = typeof msg["transcript"] === "string" ? msg["transcript"] : "";
@@ -345,7 +398,10 @@ class OpenAiCompatibleRealtimeAdapter implements RealtimeAdapter {
345
398
  if (toolCall) {
346
399
  this.stream.push(toolCall);
347
400
  }
348
- 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" });
349
405
  break;
350
406
  }
351
407
  case "error": {
@@ -356,6 +412,14 @@ class OpenAiCompatibleRealtimeAdapter implements RealtimeAdapter {
356
412
  : this.config.defaultErrorMessage;
357
413
  const code =
358
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
+ }
359
423
  const recoverable = code !== "invalid_api_key" && code !== "authentication_failed";
360
424
  this.stream.push({
361
425
  type: "error",
@@ -370,6 +434,21 @@ class OpenAiCompatibleRealtimeAdapter implements RealtimeAdapter {
370
434
  }
371
435
  }
372
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
+
373
452
  function extractFunctionCall(response: unknown): RealtimeEvent | null {
374
453
  if (!response || typeof response !== "object") return null;
375
454
  const output = (response as Record<string, unknown>)["output"];
@@ -14,6 +14,16 @@ export interface RealtimeAdapter {
14
14
  * be replayed — that would double-apply history (RFC bimodel-delegate-seam R6).
15
15
  */
16
16
  readonly supportsNativeResume?: boolean;
17
+ /** The front model owns full-duplex interaction decisions (turn-taking, barge-in). When true,
18
+ * Syrinx's InteractionPolicy runs observe-only and does not drive its own turn/interrupt decisions
19
+ * (RFC InteractionPolicy REQ-4). Absent/false → Syrinx drives. No current adapter sets this. */
20
+ readonly supportsFullDuplex?: boolean;
21
+ /** The front emits its own backchannels ("mhmm"). When true, Syrinx suppresses its own backchannel
22
+ * cues (RFC InteractionPolicy REQ-4). Absent/false → Syrinx may emit. */
23
+ readonly emitsBackchannel?: boolean;
24
+ /** Half-cascade: provider can run text-only modality (`modalities:["text"]`) so Syrinx TTS
25
+ * drives speech from assistant transcript events. Absent/false → no text-only half-cascade. */
26
+ readonly supportsTextOnlyModality?: boolean;
17
27
  };
18
28
 
19
29
  open(signal: AbortSignal): Promise<void>;
@@ -23,6 +33,15 @@ export interface RealtimeAdapter {
23
33
  * provider cannot accept text input omit it, and the bridge silently ignores typed turns for them.
24
34
  */
25
35
  sendText?(text: string): void;
36
+ /** Inject transient context without requesting a provider response. */
37
+ injectContext?(text: string): void;
38
+ /**
39
+ * Commit any buffered user input and request a response. For Syrinx-OWNED turn detection
40
+ * (provider server VAD disabled via turnDetection:null): the host calls this when its own
41
+ * endpointing (InteractionPolicy / VAD) signals end-of-turn. Optional — adapters without
42
+ * manual turn control omit it.
43
+ */
44
+ requestResponse?(): void;
26
45
  cancelResponse(audioEndMs: number): void;
27
46
  injectToolResult(toolId: string, text: string): void;
28
47
  /** Close the provider socket and end the event stream. */
@@ -47,13 +66,21 @@ export interface RealtimeResumeMessage {
47
66
  readonly content: string;
48
67
  }
49
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
+
50
76
  export type RealtimeEvent =
51
77
  | { type: "audio"; pcm16: Uint8Array; sampleRateHz: number }
52
78
  | { type: "speech_started" }
79
+ | { type: "speech_stopped" }
53
80
  | { type: "transcript"; role: "user" | "assistant"; text: string; final: boolean }
54
81
  | { type: "tool_call"; toolId: string; toolName: string; args: Record<string, unknown> }
55
82
  | { type: "response_started" }
56
- | { type: "response_done" }
83
+ | { type: "response_done"; usage?: RealtimeUsage }
57
84
  // G4: a native-resume provider issued a fresh resumption handle — persist the
58
85
  // latest one and pass it back on reconnect (Gemini `sessionResumption`).
59
86
  | { type: "resumption_handle"; handle: string }