SalexAI commited on
Commit
8ce42f6
·
verified ·
1 Parent(s): adf21ac

Update app/main.py

Browse files
Files changed (1) hide show
  1. app/main.py +217 -154
app/main.py CHANGED
@@ -1,7 +1,8 @@
 
1
  import os
2
  import json
3
  import asyncio
4
- from typing import Any, Dict, Optional
5
 
6
  from fastapi import FastAPI, WebSocket, WebSocketDisconnect
7
  from fastapi.responses import JSONResponse
@@ -10,25 +11,31 @@ import websockets
10
 
11
  load_dotenv()
12
 
13
- app = FastAPI(title="Gemini Live WS Proxy", version="1.0.0")
14
 
15
- # Gemini Live API WebSocket endpoint for BidiGenerateContent (v1beta)
16
- # (Official endpoint in the Live API WebSockets reference.)
17
  GEMINI_LIVE_WS_URL = (
18
  "wss://generativelanguage.googleapis.com/ws/"
19
  "google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent"
20
  )
21
 
22
- DEFAULT_MODEL = os.getenv("GEMINI_MODEL", "models/gemini-2.5-flash-native-audio-preview-12-2025")
23
- DEFAULT_SYSTEM = os.getenv("GEMINI_SYSTEM_INSTRUCTION", "You are a helpful assistant.")
 
 
 
24
  DEFAULT_TEMPERATURE = float(os.getenv("GEMINI_TEMPERATURE", "0.7"))
25
  DEFAULT_MAX_TOKENS = int(os.getenv("GEMINI_MAX_OUTPUT_TOKENS", "1024"))
26
 
27
- API_KEY = os.getenv("GEMINI_API_KEY", "").strip()
28
- if not API_KEY:
29
- # Don't crash import-time on HF if they haven't set secrets yet;
30
- # we will return a clear runtime error at connection time.
31
- pass
 
 
 
 
32
 
33
 
34
  @app.get("/health")
@@ -39,75 +46,59 @@ async def health():
39
  "ok": ok,
40
  "has_api_key": ok,
41
  "model": DEFAULT_MODEL,
 
 
 
 
42
  }
43
  )
44
 
45
 
46
- def _safe_get_text_from_content(content: Dict[str, Any]) -> str:
47
- """
48
- Gemini Content format typically includes:
49
- {"role": "...", "parts": [{"text": "..."} , ...]}
50
- We concatenate any text parts we see.
51
- """
52
  parts = content.get("parts") or []
53
- out = []
54
  for p in parts:
55
- if isinstance(p, dict) and "text" in p and isinstance(p["text"], str):
56
  out.append(p["text"])
57
  return "".join(out)
58
 
59
 
60
- async def _gemini_connect_and_setup(
61
- model: str,
62
- system_instruction: str,
63
- temperature: float,
64
- max_output_tokens: int,
65
- response_modalities: Optional[list] = None,
66
- ):
67
  """
68
- Opens a websocket to Gemini Live API and sends the required initial setup message.
69
- Clients should wait for setupComplete before sending further messages.
70
  """
71
- headers = {
72
- # Gemini API auth: x-goog-api-key header is required for requests. :contentReference[oaicite:2]{index=2}
73
- "x-goog-api-key": API_KEY,
74
- }
75
-
 
 
 
 
 
 
 
 
 
 
 
76
  ws = await websockets.connect(
77
  GEMINI_LIVE_WS_URL,
78
  extra_headers=headers,
79
- max_size=8 * 1024 * 1024, # allow larger payloads if needed later
80
  ping_interval=20,
81
  ping_timeout=20,
82
  )
83
 
84
- setup_payload = {
85
- "setup": {
86
- "model": model,
87
- "generationConfig": {
88
- "temperature": temperature,
89
- "maxOutputTokens": max_output_tokens,
90
- "responseModalities": response_modalities or ["TEXT"],
91
- },
92
- # Live API reference shows systemInstruction is Content; we send text-only Content.
93
- # (Docs note text parts in system instruction.) :contentReference[oaicite:3]{index=3}
94
- "systemInstruction": {
95
- "role": "system",
96
- "parts": [{"text": system_instruction}],
97
- },
98
- }
99
- }
100
-
101
  await ws.send(json.dumps(setup_payload))
102
 
103
- # Wait for setupComplete
104
  while True:
105
  raw = await ws.recv()
106
  msg = json.loads(raw)
107
  if "setupComplete" in msg:
108
  return ws
109
- # Forward other early messages if they appear, but don't block setup forever.
110
- # If Gemini returns an error-like structure, surface it.
111
  if "error" in msg:
112
  raise RuntimeError(f"Gemini setup error: {msg['error']}")
113
 
@@ -115,120 +106,192 @@ async def _gemini_connect_and_setup(
115
  @app.websocket("/ws")
116
  async def ws_proxy(client_ws: WebSocket):
117
  """
118
- Client protocol (simple):
119
- -> {"type":"text","text":"hello"}
120
- -> {"type":"configure", "model": "...", "system_instruction": "...", "temperature": 0.7, "max_output_tokens": 1024}
121
- -> {"type":"close"}
122
 
123
- Server sends:
 
 
 
 
 
 
 
 
124
  <- {"type":"ready"}
125
- <- {"type":"text_delta","text":"..."} (streaming)
 
126
  <- {"type":"turn_complete"}
127
- <- {"type":"gemini_raw","message":{...}} (debug passthrough)
128
  <- {"type":"error","message":"..."}
 
129
  """
130
  await client_ws.accept()
131
 
132
  if not API_KEY:
133
- await client_ws.send_text(
134
- json.dumps(
135
- {
136
- "type": "error",
137
- "message": "Server missing GEMINI_API_KEY env var. Set it in your Space secrets.",
138
- }
139
- )
140
- )
141
  await client_ws.close(code=1011)
142
  return
143
 
144
- # Per-connection defaults (can be overridden by configure message)
145
- model = DEFAULT_MODEL
146
- system_instruction = DEFAULT_SYSTEM
147
- temperature = DEFAULT_TEMPERATURE
148
- max_output_tokens = DEFAULT_MAX_TOKENS
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
 
150
- gemini_ws = None
151
  stop_event = asyncio.Event()
 
152
 
153
- async def ensure_gemini():
154
- nonlocal gemini_ws
155
- if gemini_ws is None:
156
- gemini_ws = await _gemini_connect_and_setup(
157
- model=model,
158
- system_instruction=system_instruction,
159
- temperature=temperature,
160
- max_output_tokens=max_output_tokens,
161
- response_modalities=["TEXT"],
162
- )
163
 
164
  async def forward_client_to_gemini():
165
- """
166
- Reads from your client WebSocket and sends appropriate Live API messages to Gemini.
167
- Uses clientContent + turnComplete for clean text turns. :contentReference[oaicite:4]{index=4}
168
- """
169
  try:
170
  while not stop_event.is_set():
171
- raw = await client_ws.receive_text()
172
- data = json.loads(raw)
173
-
174
- msg_type = data.get("type")
175
- if msg_type == "configure":
176
- # Allow config BEFORE Gemini connection is created.
177
- if gemini_ws is not None:
178
- await client_ws.send_text(
179
- json.dumps(
180
- {
181
- "type": "error",
182
- "message": "Cannot configure after session started. Open a new WS connection.",
183
- }
184
- )
185
- )
186
- continue
187
 
188
- model = data.get("model", model)
189
- system_instruction = data.get("system_instruction", system_instruction)
190
- temperature = float(data.get("temperature", temperature))
191
- max_output_tokens = int(data.get("max_output_tokens", max_output_tokens))
192
- await client_ws.send_text(json.dumps({"type": "configured"}))
193
- continue
194
 
195
- if msg_type == "close":
196
  stop_event.set()
197
  return
198
 
199
- if msg_type == "text":
200
- text = data.get("text", "")
201
- if not isinstance(text, str) or not text.strip():
 
 
202
  continue
 
 
 
 
203
 
204
- await ensure_gemini()
205
-
206
- # Send a single "turn" using clientContent.turns and turnComplete=true. :contentReference[oaicite:5]{index=5}
207
  payload = {
208
- "clientContent": {
209
- "turns": [
210
- {
211
- "role": "user",
212
- "parts": [{"text": text}],
213
- }
214
- ],
215
- "turnComplete": True,
216
  }
217
  }
218
  await gemini_ws.send(json.dumps(payload))
219
  continue
220
 
221
- # Optional: raw passthrough (advanced users)
222
- if msg_type == "live_raw":
223
- await ensure_gemini()
224
- payload = data.get("payload")
225
- if isinstance(payload, dict):
 
 
 
 
 
 
 
 
 
 
 
 
226
  await gemini_ws.send(json.dumps(payload))
227
  continue
228
 
229
- await client_ws.send_text(
230
- json.dumps({"type": "error", "message": f"Unknown message type: {msg_type}"})
231
- )
 
 
 
232
 
233
  except WebSocketDisconnect:
234
  stop_event.set()
@@ -240,39 +303,40 @@ async def ws_proxy(client_ws: WebSocket):
240
  pass
241
 
242
  async def forward_gemini_to_client():
243
- """
244
- Reads Gemini Live API server messages and forwards useful pieces to your client.
245
- We extract text from serverContent.modelTurn.parts[].text when present. :contentReference[oaicite:6]{index=6}
246
- """
247
  try:
248
- await ensure_gemini()
249
- await client_ws.send_text(json.dumps({"type": "ready"}))
250
-
251
  while not stop_event.is_set():
252
  raw = await gemini_ws.recv()
253
  msg = json.loads(raw)
254
 
255
- # Optional debug passthrough:
256
- await client_ws.send_text(json.dumps({"type": "gemini_raw", "message": msg}))
257
 
258
- # The main streaming content arrives under "serverContent"
259
  server_content = msg.get("serverContent")
260
  if isinstance(server_content, dict):
261
- # modelTurn is Content (role+parts)
262
  model_turn = server_content.get("modelTurn")
263
  if isinstance(model_turn, dict):
264
- delta = _safe_get_text_from_content(model_turn)
265
- if delta:
266
- await client_ws.send_text(json.dumps({"type": "text_delta", "text": delta}))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
267
 
268
- # When generationComplete true, we end the turn
269
  if server_content.get("generationComplete") is True:
270
  await client_ws.send_text(json.dumps({"type": "turn_complete"}))
271
 
272
- # Tool calls (if you later enable tools in setup)
273
- if "toolCall" in msg:
274
- await client_ws.send_text(json.dumps({"type": "tool_call", "toolCall": msg["toolCall"]}))
275
-
276
  if "goAway" in msg:
277
  await client_ws.send_text(json.dumps({"type": "go_away", "goAway": msg["goAway"]}))
278
 
@@ -284,7 +348,6 @@ async def ws_proxy(client_ws: WebSocket):
284
  pass
285
 
286
  try:
287
- # Run both directions
288
  await asyncio.gather(forward_client_to_gemini(), forward_gemini_to_client())
289
  finally:
290
  stop_event.set()
 
1
+ # app/main.py
2
  import os
3
  import json
4
  import asyncio
5
+ from typing import Any, Dict, Optional, List
6
 
7
  from fastapi import FastAPI, WebSocket, WebSocketDisconnect
8
  from fastapi.responses import JSONResponse
 
11
 
12
  load_dotenv()
13
 
14
+ app = FastAPI(title="Gemini Live Native-Audio WS Proxy", version="2.0.0")
15
 
16
+ # Gemini Live API WebSocket endpoint (v1beta, BidiGenerateContent)
 
17
  GEMINI_LIVE_WS_URL = (
18
  "wss://generativelanguage.googleapis.com/ws/"
19
  "google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent"
20
  )
21
 
22
+ API_KEY = os.getenv("GEMINI_API_KEY", "").strip()
23
+
24
+ # Defaults (override via HF Space variables)
25
+ DEFAULT_MODEL = os.getenv("GEMINI_MODEL", "models/gemini-2.0-flash-live-001")
26
+ DEFAULT_SYSTEM = os.getenv("GEMINI_SYSTEM_INSTRUCTION", "You are a helpful assistant for a school coding club.")
27
  DEFAULT_TEMPERATURE = float(os.getenv("GEMINI_TEMPERATURE", "0.7"))
28
  DEFAULT_MAX_TOKENS = int(os.getenv("GEMINI_MAX_OUTPUT_TOKENS", "1024"))
29
 
30
+ # Native-audio config defaults
31
+ DEFAULT_VOICE = os.getenv("GEMINI_VOICE_NAME", "Kore")
32
+ # input audio: most common is 16k PCM16 mono
33
+ DEFAULT_INPUT_RATE = int(os.getenv("GEMINI_INPUT_AUDIO_RATE", "16000"))
34
+ # output audio: docs commonly mention 24k PCM16
35
+ DEFAULT_OUTPUT_RATE = int(os.getenv("GEMINI_OUTPUT_AUDIO_RATE", "24000"))
36
+
37
+ # Debug passthrough (set to "1" to enable)
38
+ DEBUG_GEMINI_RAW = os.getenv("DEBUG_GEMINI_RAW", "0").strip() == "1"
39
 
40
 
41
  @app.get("/health")
 
46
  "ok": ok,
47
  "has_api_key": ok,
48
  "model": DEFAULT_MODEL,
49
+ "voice": DEFAULT_VOICE,
50
+ "input_rate": DEFAULT_INPUT_RATE,
51
+ "output_rate": DEFAULT_OUTPUT_RATE,
52
+ "debug_raw": DEBUG_GEMINI_RAW,
53
  }
54
  )
55
 
56
 
57
+ def _extract_text_parts(content: Dict[str, Any]) -> str:
 
 
 
 
 
58
  parts = content.get("parts") or []
59
+ out: List[str] = []
60
  for p in parts:
61
+ if isinstance(p, dict) and isinstance(p.get("text"), str):
62
  out.append(p["text"])
63
  return "".join(out)
64
 
65
 
66
+ def _extract_inline_audio_parts(content: Dict[str, Any]) -> List[Dict[str, str]]:
 
 
 
 
 
 
67
  """
68
+ Returns list of {"mime": "...", "data": "base64..."} for any inlineData parts.
 
69
  """
70
+ parts = content.get("parts") or []
71
+ out: List[Dict[str, str]] = []
72
+ for p in parts:
73
+ if not isinstance(p, dict):
74
+ continue
75
+ inline = p.get("inlineData")
76
+ if isinstance(inline, dict):
77
+ data = inline.get("data")
78
+ mime = inline.get("mimeType")
79
+ if isinstance(data, str) and isinstance(mime, str):
80
+ out.append({"mime": mime, "data": data})
81
+ return out
82
+
83
+
84
+ async def _gemini_ws_connect(setup_payload: Dict[str, Any]):
85
+ headers = {"x-goog-api-key": API_KEY}
86
  ws = await websockets.connect(
87
  GEMINI_LIVE_WS_URL,
88
  extra_headers=headers,
89
+ max_size=16 * 1024 * 1024,
90
  ping_interval=20,
91
  ping_timeout=20,
92
  )
93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  await ws.send(json.dumps(setup_payload))
95
 
96
+ # wait for setupComplete
97
  while True:
98
  raw = await ws.recv()
99
  msg = json.loads(raw)
100
  if "setupComplete" in msg:
101
  return ws
 
 
102
  if "error" in msg:
103
  raise RuntimeError(f"Gemini setup error: {msg['error']}")
104
 
 
106
  @app.websocket("/ws")
107
  async def ws_proxy(client_ws: WebSocket):
108
  """
109
+ Client protocol (native-audio + VAD friendly):
110
+ -> {"type":"configure", "model": "...", "system_instruction": "...", "temperature": 0.7,
111
+ "max_output_tokens": 1024, "voice": "Kore", "input_rate": 16000}
112
+ (optional, must be first; else defaults are used)
113
 
114
+ -> {"type":"audio","data":"<base64 pcm16 mono>","rate":16000}
115
+ (send repeatedly while user is speaking)
116
+
117
+ -> {"type":"audio_end"}
118
+ (send when VAD decides user stopped speaking; triggers assistant response)
119
+
120
+ -> {"type":"text","text":"..."} (optional helper; NOT the main mode for native audio)
121
+
122
+ Server -> client:
123
  <- {"type":"ready"}
124
+ <- {"type":"text_delta","text":"..."} (assistant text parts, if any)
125
+ <- {"type":"audio_delta","mime":"...","data":"..."} (assistant audio chunks)
126
  <- {"type":"turn_complete"}
 
127
  <- {"type":"error","message":"..."}
128
+ <- {"type":"gemini_raw","message":{...}} (only if DEBUG_GEMINI_RAW=1)
129
  """
130
  await client_ws.accept()
131
 
132
  if not API_KEY:
133
+ await client_ws.send_text(json.dumps({"type": "error", "message": "Missing GEMINI_API_KEY on server."}))
 
 
 
 
 
 
 
134
  await client_ws.close(code=1011)
135
  return
136
 
137
+ # --- Phase 1: accept optional configure before connecting to Gemini ---
138
+ cfg = {
139
+ "model": DEFAULT_MODEL,
140
+ "system_instruction": DEFAULT_SYSTEM,
141
+ "temperature": DEFAULT_TEMPERATURE,
142
+ "max_output_tokens": DEFAULT_MAX_TOKENS,
143
+ "voice": DEFAULT_VOICE,
144
+ "input_rate": DEFAULT_INPUT_RATE,
145
+ }
146
+
147
+ async def _wait_for_optional_config(timeout_s: float = 1.2):
148
+ try:
149
+ raw = await asyncio.wait_for(client_ws.receive_text(), timeout=timeout_s)
150
+ except asyncio.TimeoutError:
151
+ return
152
+ except Exception:
153
+ return
154
+
155
+ data = json.loads(raw)
156
+ if data.get("type") != "configure":
157
+ # if first message is not configure, we treat it as "not configure"
158
+ # and stash it for later by putting it into a queue (simple: handle inline)
159
+ return data
160
+
161
+ # apply config
162
+ if isinstance(data.get("model"), str) and data["model"].strip():
163
+ cfg["model"] = data["model"].strip()
164
+ if isinstance(data.get("system_instruction"), str) and data["system_instruction"].strip():
165
+ cfg["system_instruction"] = data["system_instruction"].strip()
166
+ if data.get("temperature") is not None:
167
+ try:
168
+ cfg["temperature"] = float(data["temperature"])
169
+ except Exception:
170
+ pass
171
+ if data.get("max_output_tokens") is not None:
172
+ try:
173
+ cfg["max_output_tokens"] = int(data["max_output_tokens"])
174
+ except Exception:
175
+ pass
176
+ if isinstance(data.get("voice"), str) and data["voice"].strip():
177
+ cfg["voice"] = data["voice"].strip()
178
+ if data.get("input_rate") is not None:
179
+ try:
180
+ cfg["input_rate"] = int(data["input_rate"])
181
+ except Exception:
182
+ pass
183
+
184
+ await client_ws.send_text(json.dumps({"type": "configured"}))
185
+ return None
186
+
187
+ first_non_config = await _wait_for_optional_config()
188
+
189
+ # --- Phase 2: connect to Gemini with native-audio setup ---
190
+ # NOTE: For native-audio models, AUDIO modality is required.
191
+ setup_payload = {
192
+ "setup": {
193
+ "model": cfg["model"],
194
+ "generationConfig": {
195
+ "temperature": cfg["temperature"],
196
+ "maxOutputTokens": cfg["max_output_tokens"],
197
+ "responseModalities": ["AUDIO"],
198
+ "speechConfig": {
199
+ "voiceConfig": {
200
+ "prebuiltVoiceConfig": {
201
+ "voiceName": cfg["voice"],
202
+ }
203
+ }
204
+ },
205
+ },
206
+ # Enable transcripts so Scratch can display text while audio plays
207
+ "inputAudioTranscription": {},
208
+ "outputAudioTranscription": {},
209
+ "systemInstruction": {
210
+ "role": "system",
211
+ "parts": [{"text": cfg["system_instruction"]}],
212
+ },
213
+ }
214
+ }
215
 
 
216
  stop_event = asyncio.Event()
217
+ gemini_ws = None
218
 
219
+ try:
220
+ gemini_ws = await _gemini_ws_connect(setup_payload)
221
+ await client_ws.send_text(json.dumps({"type": "ready"}))
222
+ except Exception as e:
223
+ await client_ws.send_text(json.dumps({"type": "error", "message": f"Gemini setup failed: {e}"}))
224
+ await client_ws.close(code=1011)
225
+ return
226
+
227
+ # If we consumed a non-config first message, we need to handle it.
228
+ pending_first = first_non_config
229
 
230
  async def forward_client_to_gemini():
231
+ nonlocal pending_first
 
 
 
232
  try:
233
  while not stop_event.is_set():
234
+ if pending_first is not None:
235
+ data = pending_first
236
+ pending_first = None
237
+ else:
238
+ raw = await client_ws.receive_text()
239
+ data = json.loads(raw)
 
 
 
 
 
 
 
 
 
 
240
 
241
+ t = data.get("type")
 
 
 
 
 
242
 
243
+ if t == "close":
244
  stop_event.set()
245
  return
246
 
247
+ if t == "audio":
248
+ # expects base64 PCM16 mono
249
+ b64 = data.get("data")
250
+ rate = data.get("rate", cfg["input_rate"])
251
+ if not isinstance(b64, str) or not b64:
252
  continue
253
+ try:
254
+ rate_i = int(rate)
255
+ except Exception:
256
+ rate_i = cfg["input_rate"]
257
 
 
 
 
258
  payload = {
259
+ "realtimeInput": {
260
+ "audio": {
261
+ "data": b64,
262
+ "mimeType": f"audio/pcm;rate={rate_i}",
263
+ }
 
 
 
264
  }
265
  }
266
  await gemini_ws.send(json.dumps(payload))
267
  continue
268
 
269
+ if t == "audio_end":
270
+ # tell Gemini the input stream ended for this turn
271
+ payload = {"realtimeInput": {"audioStreamEnd": True}}
272
+ await gemini_ws.send(json.dumps(payload))
273
+ continue
274
+
275
+ if t == "text":
276
+ # Optional helper: send text as a turn (some native-audio sessions still accept it),
277
+ # but for voice-first you should mainly use audio.
278
+ text = data.get("text", "")
279
+ if isinstance(text, str) and text.strip():
280
+ payload = {
281
+ "clientContent": {
282
+ "turns": [{"role": "user", "parts": [{"text": text.strip()}]}],
283
+ "turnComplete": True,
284
+ }
285
+ }
286
  await gemini_ws.send(json.dumps(payload))
287
  continue
288
 
289
+ # Advanced passthrough
290
+ if t == "live_raw" and isinstance(data.get("payload"), dict):
291
+ await gemini_ws.send(json.dumps(data["payload"]))
292
+ continue
293
+
294
+ await client_ws.send_text(json.dumps({"type": "error", "message": f"Unknown message type: {t}"}))
295
 
296
  except WebSocketDisconnect:
297
  stop_event.set()
 
303
  pass
304
 
305
  async def forward_gemini_to_client():
 
 
 
 
306
  try:
 
 
 
307
  while not stop_event.is_set():
308
  raw = await gemini_ws.recv()
309
  msg = json.loads(raw)
310
 
311
+ if DEBUG_GEMINI_RAW:
312
+ await client_ws.send_text(json.dumps({"type": "gemini_raw", "message": msg}))
313
 
 
314
  server_content = msg.get("serverContent")
315
  if isinstance(server_content, dict):
 
316
  model_turn = server_content.get("modelTurn")
317
  if isinstance(model_turn, dict):
318
+ # text parts
319
+ txt = _extract_text_parts(model_turn)
320
+ if txt:
321
+ await client_ws.send_text(json.dumps({"type": "text_delta", "text": txt}))
322
+
323
+ # audio parts (inlineData)
324
+ audios = _extract_inline_audio_parts(model_turn)
325
+ for a in audios:
326
+ await client_ws.send_text(
327
+ json.dumps({"type": "audio_delta", "mime": a["mime"], "data": a["data"]})
328
+ )
329
+
330
+ # Some implementations also include transcription fields; pass through if present
331
+ out_tx = server_content.get("outputTranscription")
332
+ if isinstance(out_tx, dict) and isinstance(out_tx.get("text"), str):
333
+ await client_ws.send_text(
334
+ json.dumps({"type": "output_transcript_delta", "text": out_tx["text"]})
335
+ )
336
 
 
337
  if server_content.get("generationComplete") is True:
338
  await client_ws.send_text(json.dumps({"type": "turn_complete"}))
339
 
 
 
 
 
340
  if "goAway" in msg:
341
  await client_ws.send_text(json.dumps({"type": "go_away", "goAway": msg["goAway"]}))
342
 
 
348
  pass
349
 
350
  try:
 
351
  await asyncio.gather(forward_client_to_gemini(), forward_gemini_to_client())
352
  finally:
353
  stop_event.set()