CB commited on
Commit
c314d98
·
verified ·
1 Parent(s): 1158077

Update streamlit_app.py

Browse files
Files changed (1) hide show
  1. streamlit_app.py +121 -79
streamlit_app.py CHANGED
@@ -1,4 +1,4 @@
1
- # streamlit_app.py
2
  import os
3
  import time
4
  import string
@@ -9,6 +9,7 @@ from pathlib import Path
9
  from difflib import SequenceMatcher
10
  import concurrent.futures
11
  import json
 
12
 
13
  import yt_dlp
14
  import ffmpeg
@@ -37,11 +38,15 @@ except Exception:
37
  upload_file = get_file = None
38
  HAS_GENAI = False
39
 
 
 
 
 
40
  st.set_page_config(page_title="Generate the story of videos", layout="wide")
41
  DATA_DIR = Path("./data")
42
  DATA_DIR.mkdir(exist_ok=True)
43
 
44
- # ---- Session defaults ----
45
  st.session_state.setdefault("videos", "")
46
  st.session_state.setdefault("loop_video", False)
47
  st.session_state.setdefault("uploaded_file", None)
@@ -55,10 +60,11 @@ st.session_state.setdefault("api_key", os.getenv("GOOGLE_API_KEY", ""))
55
  st.session_state.setdefault("last_model", "")
56
  st.session_state.setdefault("upload_progress", {"uploaded": 0, "total": 0})
57
  st.session_state.setdefault("last_url_value", "")
58
- st.session_state.setdefault("processing_timeout", 900) # increased default to 15m
59
- st.session_state.setdefault("generation_timeout", 300) # for Responses generate
 
60
 
61
- # ---- Helpers ----
62
  def sanitize_filename(path_str: str):
63
  name = Path(path_str).name
64
  return name.lower().translate(str.maketrans("", "", string.punctuation)).replace(" ", "_")
@@ -129,7 +135,7 @@ def configure_genai_if_needed():
129
  pass
130
  return True
131
 
132
- # ---- Agent management ----
133
  _agent = None
134
  def maybe_create_agent(model_id: str):
135
  global _agent
@@ -167,12 +173,12 @@ if current_url != st.session_state.get("last_url_value"):
167
  clear_all_video_state()
168
  st.session_state["last_url_value"] = current_url
169
 
170
- # ---- Sidebar UI ----
171
  st.sidebar.header("Video Input")
172
  st.sidebar.text_input("Video URL", key="url", placeholder="https://")
173
 
174
  settings_exp = st.sidebar.expander("Settings", expanded=False)
175
- model_input = settings_exp.text_input("Gemini Model (short name)", "gemini-2.0-flash-lite", key="model_input")
176
  settings_exp.text_input("Google API Key", key="api_key", value=os.getenv("GOOGLE_API_KEY", ""), type="password")
177
  default_prompt = (
178
  "Watch the video and provide a detailed behavioral report focusing on human actions, interactions, posture, movement, and apparent intent. Keep language professional. Include a list of observations for notable events."
@@ -203,21 +209,22 @@ safety_settings = [
203
  {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "OFF"},
204
  ]
205
 
206
- # ---- Upload & processing helpers ----
207
- def upload_video_sdk(filepath: str):
208
  key = get_effective_api_key()
209
  if not key:
210
  raise RuntimeError("No API key provided")
211
  if not HAS_GENAI or upload_file is None:
212
  raise RuntimeError("google.generativeai SDK not available; cannot upload")
213
  genai.configure(api_key=key)
214
- return upload_file(filepath)
 
 
 
 
 
215
 
216
- def wait_for_processed(file_obj, timeout: int = None):
217
- """
218
- Poll get_file until file is no longer PROCESSING.
219
- Retries get_file on transient errors with exponential backoff.
220
- """
221
  if timeout is None:
222
  timeout = st.session_state.get("processing_timeout", 900)
223
  if not HAS_GENAI or get_file is None:
@@ -227,6 +234,7 @@ def wait_for_processed(file_obj, timeout: int = None):
227
  if not name:
228
  return file_obj
229
  backoff = 1.0
 
230
  while True:
231
  try:
232
  obj = get_file(name)
@@ -238,13 +246,24 @@ def wait_for_processed(file_obj, timeout: int = None):
238
  continue
239
 
240
  state = getattr(obj, "state", None)
241
- if not state or getattr(state, "name", None) != "PROCESSING":
 
 
 
 
 
 
 
 
 
 
242
  return obj
243
 
244
  if time.time() - start > timeout:
245
  raise TimeoutError(f"File processing timed out after {int(time.time() - start)}s")
246
  time.sleep(backoff)
247
  backoff = min(backoff * 2, 8.0)
 
248
 
249
  def remove_prompt_echo(prompt: str, text: str, check_len: int = 600, ratio_threshold: float = 0.68):
250
  if not prompt or not text:
@@ -285,8 +304,8 @@ def compress_video_if_large(local_path: str, threshold_mb: int = 50):
285
  st.session_state["last_error"] = f"Video compression failed: {e}\n{traceback.format_exc()}"
286
  return local_path, False
287
 
288
- # ---- Robust Responses API caller adapted for varying model versions ----
289
- def generate_via_responses_api(prompt_text: str, processed, model_used: str, max_tokens: int = 1024, timeout: int = 300):
290
  key = get_effective_api_key()
291
  if not key:
292
  raise RuntimeError("No API key provided")
@@ -300,79 +319,98 @@ def generate_via_responses_api(prompt_text: str, processed, model_used: str, max
300
  system_msg = {"role": "system", "content": prompt_text}
301
  user_msg = {"role": "user", "content": "Please summarize the attached video."}
302
 
303
- # Some model versions and SDK releases expect messages, some older ones expect input with files.
304
  call_variants = [
305
  {"messages": [system_msg, user_msg], "files": [{"name": fname}], "safety_settings": safety_settings, "max_output_tokens": max_tokens},
306
  {"input": [{"text": prompt_text, "files": [{"name": fname}]}], "safety_settings": safety_settings, "max_output_tokens": max_tokens},
307
  ]
308
 
309
- last_exc = None
 
 
 
310
  start = time.time()
 
311
  backoff = 1.0
312
- while True:
313
- for payload in call_variants:
314
- try:
315
- response = genai.responses.generate(model=model_used, **payload)
316
- # If successful, normalize below
317
- return _normalize_genai_response(response)
318
- except Exception as e:
319
- last_exc = e
320
- # If it's a transient server error, let outer retry/backoff handle it
321
- # Quick heuristic: inspect message for INTERNAL/UNAVAILABLE/DeadlineExceeded
322
- msg = str(e).lower()
323
- if any(k in msg for k in ("internal", "unavailable", "deadlineexceeded", "deadline exceeded", "timeout", "rate limit")):
324
- # will retry below
325
- pass
326
- else:
327
- # If it's a clear invalid-argument or permission error, bubble up immediately
328
- raise
329
- if time.time() - start > timeout:
330
- raise TimeoutError(f"Responses.generate timed out after {timeout}s: last error: {last_exc}")
331
- time.sleep(backoff)
332
- backoff = min(backoff * 2, 8.0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
333
 
334
  def _normalize_genai_response(response):
335
- # Accept dict or object shapes. Extract text pieces robustly and join.
336
  outputs = []
337
  if response is None:
338
  return ""
339
-
340
- # If it's an object with attributes
341
  if not isinstance(response, dict):
342
  try:
343
  response = json.loads(str(response))
344
  except Exception:
345
- # fallback to attribute access
346
  pass
347
-
348
- # Strategy: check common keys
349
  candidate_lists = []
350
- for key in ("output", "candidates", "items", "responses", "choices"):
351
- val = response.get(key) if isinstance(response, dict) else None
352
- if isinstance(val, list) and val:
353
- candidate_lists.append(val)
354
- if not candidate_lists:
355
- # fallback: any list value
356
- if isinstance(response, dict):
357
- for v in response.values():
358
- if isinstance(v, list) and v:
359
- candidate_lists.append(v)
360
- break
361
-
362
  text_pieces = []
363
  for lst in candidate_lists:
364
  for item in lst:
365
  if not item:
366
  continue
367
  if isinstance(item, dict):
368
- # common text keys
369
  for k in ("content", "text", "message", "output_text", "output"):
370
  t = item.get(k)
371
  if t:
372
  text_pieces.append(str(t).strip())
373
  break
374
  else:
375
- # nested forms
376
  if "content" in item and isinstance(item["content"], list):
377
  for part in item["content"]:
378
  if isinstance(part, dict):
@@ -384,23 +422,18 @@ def _normalize_genai_response(response):
384
  elif isinstance(item, str):
385
  text_pieces.append(item.strip())
386
  else:
387
- # try attribute access
388
  try:
389
  t = getattr(item, "text", None) or getattr(item, "content", None)
390
  if t:
391
  text_pieces.append(str(t).strip())
392
  except Exception:
393
  pass
394
-
395
- # If still empty, try top-level text fields
396
  if not text_pieces and isinstance(response, dict):
397
  for k in ("text", "message", "output_text"):
398
  v = response.get(k)
399
  if v:
400
  text_pieces.append(str(v).strip())
401
  break
402
-
403
- # deduplicate preserving order
404
  seen = set()
405
  filtered = []
406
  for t in text_pieces:
@@ -411,7 +444,7 @@ def _normalize_genai_response(response):
411
  seen.add(t)
412
  return "\n\n".join(filtered).strip()
413
 
414
- # ---- Layout ----
415
  col1, col2 = st.columns([1, 3])
416
  with col1:
417
  generate_now = st.button("Generate the story", type="primary", disabled=not bool(get_effective_api_key()))
@@ -461,7 +494,7 @@ if st.session_state["videos"]:
461
  except Exception:
462
  pass
463
 
464
- # ---- Main generation flow ----
465
  if generate_now and not st.session_state.get("busy"):
466
  if not st.session_state.get("videos"):
467
  st.error("No video loaded. Use 'Load Video' in the sidebar.")
@@ -478,7 +511,7 @@ if generate_now and not st.session_state.get("busy"):
478
  except Exception:
479
  pass
480
 
481
- model_id = (st.session_state.get("model_input") or "gemini-2.0-flash-lite").strip()
482
  if st.session_state.get("last_model") != model_id:
483
  st.session_state["last_model"] = ""
484
  maybe_create_agent(model_id)
@@ -501,9 +534,7 @@ if generate_now and not st.session_state.get("busy"):
501
  upload_path, compressed = compress_video_if_large(local_path)
502
 
503
  with st.spinner(f"Uploading video{' (compressed)' if compressed else ''}..."):
504
- # Provide an upload progress bar UI while calling upload_file.
505
- progress_placeholder = st.empty()
506
- progress_bar = None
507
  try:
508
  uploaded = upload_video_sdk(upload_path)
509
  except Exception as e:
@@ -512,11 +543,15 @@ if generate_now and not st.session_state.get("busy"):
512
  raise
513
 
514
  try:
515
- # Show a more informative processing progress area
516
  processing_placeholder = st.empty()
517
  processing_bar = processing_placeholder.progress(0)
518
- start_wait = time.time()
519
- processed = wait_for_processed(uploaded, timeout=st.session_state.get("processing_timeout", 900))
 
 
 
 
 
520
  processing_bar.progress(100)
521
  processing_placeholder.success("Processing complete")
522
  except Exception as e:
@@ -535,7 +570,6 @@ if generate_now and not st.session_state.get("busy"):
535
  max_tokens = 2048 if "2.5" in model_used else 1024
536
  est_tokens = max_tokens
537
 
538
- # Try Agent first, fallback to Responses API
539
  agent = maybe_create_agent(model_used)
540
  debug_info = {"agent_attempted": False, "agent_ok": False, "agent_error": None, "agent_response_has_text": False}
541
  if agent:
@@ -567,8 +601,16 @@ if generate_now and not st.session_state.get("busy"):
567
 
568
  if not out:
569
  try:
570
- with st.spinner("Generating description via Responses API..."):
571
- out = generate_via_responses_api(prompt_text, processed, model_used, max_tokens=max_tokens, timeout=st.session_state.get("generation_timeout", 300))
 
 
 
 
 
 
 
 
572
  except Exception as e:
573
  tb = traceback.format_exc()
574
  st.session_state["last_error"] = f"Responses API error: {e}\n\nDebug: {debug_info}\n\nTraceback:\n{tb}"
 
1
+ # streamlit_app_enhanced.py
2
  import os
3
  import time
4
  import string
 
9
  from difflib import SequenceMatcher
10
  import concurrent.futures
11
  import json
12
+ import logging
13
 
14
  import yt_dlp
15
  import ffmpeg
 
38
  upload_file = get_file = None
39
  HAS_GENAI = False
40
 
41
+ # Logging
42
+ logging.basicConfig(level=logging.INFO)
43
+ logger = logging.getLogger("video_ai")
44
+
45
  st.set_page_config(page_title="Generate the story of videos", layout="wide")
46
  DATA_DIR = Path("./data")
47
  DATA_DIR.mkdir(exist_ok=True)
48
 
49
+ # Session defaults
50
  st.session_state.setdefault("videos", "")
51
  st.session_state.setdefault("loop_video", False)
52
  st.session_state.setdefault("uploaded_file", None)
 
60
  st.session_state.setdefault("last_model", "")
61
  st.session_state.setdefault("upload_progress", {"uploaded": 0, "total": 0})
62
  st.session_state.setdefault("last_url_value", "")
63
+ st.session_state.setdefault("processing_timeout", 900)
64
+ st.session_state.setdefault("generation_timeout", 300)
65
+ st.session_state.setdefault("preferred_model", "gemini-2.5-flash-lite")
66
 
67
+ # Helpers (kept in-file for single-file deliverable)
68
  def sanitize_filename(path_str: str):
69
  name = Path(path_str).name
70
  return name.lower().translate(str.maketrans("", "", string.punctuation)).replace(" ", "_")
 
135
  pass
136
  return True
137
 
138
+ # Agent management
139
  _agent = None
140
  def maybe_create_agent(model_id: str):
141
  global _agent
 
173
  clear_all_video_state()
174
  st.session_state["last_url_value"] = current_url
175
 
176
+ # Sidebar UI
177
  st.sidebar.header("Video Input")
178
  st.sidebar.text_input("Video URL", key="url", placeholder="https://")
179
 
180
  settings_exp = st.sidebar.expander("Settings", expanded=False)
181
+ model_input = settings_exp.text_input("Preferred Gemini Model (short name)", st.session_state.get("preferred_model", "gemini-2.5-flash-lite"), key="model_input")
182
  settings_exp.text_input("Google API Key", key="api_key", value=os.getenv("GOOGLE_API_KEY", ""), type="password")
183
  default_prompt = (
184
  "Watch the video and provide a detailed behavioral report focusing on human actions, interactions, posture, movement, and apparent intent. Keep language professional. Include a list of observations for notable events."
 
209
  {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "OFF"},
210
  ]
211
 
212
+ # Upload & processing helpers
213
+ def upload_video_sdk(filepath: str, progress_callback=None):
214
  key = get_effective_api_key()
215
  if not key:
216
  raise RuntimeError("No API key provided")
217
  if not HAS_GENAI or upload_file is None:
218
  raise RuntimeError("google.generativeai SDK not available; cannot upload")
219
  genai.configure(api_key=key)
220
+ # upload_file doesn't offer progress hooks in SDK; attempt best-effort by streaming in chunks if possible
221
+ # Fall back to direct upload_file call for compatibility
222
+ try:
223
+ return upload_file(filepath)
224
+ except Exception as e:
225
+ raise
226
 
227
+ def wait_for_processed(file_obj, timeout: int = None, progress_callback=None):
 
 
 
 
228
  if timeout is None:
229
  timeout = st.session_state.get("processing_timeout", 900)
230
  if not HAS_GENAI or get_file is None:
 
234
  if not name:
235
  return file_obj
236
  backoff = 1.0
237
+ last_state = None
238
  while True:
239
  try:
240
  obj = get_file(name)
 
246
  continue
247
 
248
  state = getattr(obj, "state", None)
249
+ state_name = getattr(state, "name", None) if state else None
250
+ if progress_callback:
251
+ # show a simple heuristic percent while PROCESSING
252
+ elapsed = int(time.time() - start)
253
+ pct = 100 if not state_name else (50 if state_name == "PROCESSING" else 100)
254
+ try:
255
+ progress_callback(min(100, pct), elapsed, state_name)
256
+ except Exception:
257
+ pass
258
+
259
+ if not state_name or state_name != "PROCESSING":
260
  return obj
261
 
262
  if time.time() - start > timeout:
263
  raise TimeoutError(f"File processing timed out after {int(time.time() - start)}s")
264
  time.sleep(backoff)
265
  backoff = min(backoff * 2, 8.0)
266
+ last_state = state_name
267
 
268
  def remove_prompt_echo(prompt: str, text: str, check_len: int = 600, ratio_threshold: float = 0.68):
269
  if not prompt or not text:
 
304
  st.session_state["last_error"] = f"Video compression failed: {e}\n{traceback.format_exc()}"
305
  return local_path, False
306
 
307
+ # Robust Responses API caller with retries and auto-fallback to older model on certain failures
308
+ def generate_via_responses_api(prompt_text: str, processed, model_used: str, max_tokens: int = 1024, timeout: int = 300, progress_callback=None):
309
  key = get_effective_api_key()
310
  if not key:
311
  raise RuntimeError("No API key provided")
 
319
  system_msg = {"role": "system", "content": prompt_text}
320
  user_msg = {"role": "user", "content": "Please summarize the attached video."}
321
 
 
322
  call_variants = [
323
  {"messages": [system_msg, user_msg], "files": [{"name": fname}], "safety_settings": safety_settings, "max_output_tokens": max_tokens},
324
  {"input": [{"text": prompt_text, "files": [{"name": fname}]}], "safety_settings": safety_settings, "max_output_tokens": max_tokens},
325
  ]
326
 
327
+ def is_transient_error(e_text: str):
328
+ txt = str(e_text).lower()
329
+ return any(k in txt for k in ("internal", "unavailable", "deadlineexceeded", "deadline exceeded", "timeout", "rate limit", "503", "502", "500"))
330
+
331
  start = time.time()
332
+ last_exc = None
333
  backoff = 1.0
334
+ max_total = timeout
335
+ attempts = 0
336
+ tried_models = []
337
+ preferred_model = model_used or st.session_state.get("preferred_model", "gemini-2.5-flash-lite")
338
+ fallback_model = "gemini-2.0-flash-lite" if "2.5" in preferred_model else None
339
+ models_to_try = [preferred_model] + ([fallback_model] if fallback_model else [])
340
+ for m in models_to_try:
341
+ if not m:
342
+ continue
343
+ tried_models.append(m)
344
+ # per-model attempt window
345
+ model_start = time.time()
346
+ while True:
347
+ attempts += 1
348
+ for payload in call_variants:
349
+ try:
350
+ if progress_callback:
351
+ elapsed = int(time.time() - start)
352
+ try:
353
+ progress_callback("starting_generation", elapsed, {"model": m, "attempt": attempts})
354
+ except Exception:
355
+ pass
356
+ response = genai.responses.generate(model=m, **payload)
357
+ text = _normalize_genai_response(response)
358
+ if progress_callback:
359
+ elapsed = int(time.time() - start)
360
+ try:
361
+ progress_callback("generation_complete", elapsed, {"model": m})
362
+ except Exception:
363
+ pass
364
+ return text
365
+ except Exception as e:
366
+ last_exc = e
367
+ msg = str(e)
368
+ logger.warning("Responses.generate error on model %s attempt %s: %s", m, attempts, msg)
369
+ if not is_transient_error(msg):
370
+ # Non-transient: rethrow to surface to caller
371
+ raise
372
+ # transient: will retry for this model up to timeout
373
+ if time.time() - start > max_total:
374
+ break
375
+ time.sleep(backoff)
376
+ backoff = min(backoff * 2, 8.0)
377
+ if time.time() - model_start > max_total:
378
+ break
379
+ # try next model (fallback)
380
+ raise TimeoutError(f"Responses.generate failed after trying models {tried_models}: last error: {last_exc}")
381
 
382
  def _normalize_genai_response(response):
 
383
  outputs = []
384
  if response is None:
385
  return ""
 
 
386
  if not isinstance(response, dict):
387
  try:
388
  response = json.loads(str(response))
389
  except Exception:
 
390
  pass
 
 
391
  candidate_lists = []
392
+ if isinstance(response, dict):
393
+ for key in ("output", "candidates", "items", "responses", "choices"):
394
+ val = response.get(key)
395
+ if isinstance(val, list) and val:
396
+ candidate_lists.append(val)
397
+ if not candidate_lists and isinstance(response, dict):
398
+ for v in response.values():
399
+ if isinstance(v, list) and v:
400
+ candidate_lists.append(v)
401
+ break
 
 
402
  text_pieces = []
403
  for lst in candidate_lists:
404
  for item in lst:
405
  if not item:
406
  continue
407
  if isinstance(item, dict):
 
408
  for k in ("content", "text", "message", "output_text", "output"):
409
  t = item.get(k)
410
  if t:
411
  text_pieces.append(str(t).strip())
412
  break
413
  else:
 
414
  if "content" in item and isinstance(item["content"], list):
415
  for part in item["content"]:
416
  if isinstance(part, dict):
 
422
  elif isinstance(item, str):
423
  text_pieces.append(item.strip())
424
  else:
 
425
  try:
426
  t = getattr(item, "text", None) or getattr(item, "content", None)
427
  if t:
428
  text_pieces.append(str(t).strip())
429
  except Exception:
430
  pass
 
 
431
  if not text_pieces and isinstance(response, dict):
432
  for k in ("text", "message", "output_text"):
433
  v = response.get(k)
434
  if v:
435
  text_pieces.append(str(v).strip())
436
  break
 
 
437
  seen = set()
438
  filtered = []
439
  for t in text_pieces:
 
444
  seen.add(t)
445
  return "\n\n".join(filtered).strip()
446
 
447
+ # Layout
448
  col1, col2 = st.columns([1, 3])
449
  with col1:
450
  generate_now = st.button("Generate the story", type="primary", disabled=not bool(get_effective_api_key()))
 
494
  except Exception:
495
  pass
496
 
497
+ # Main generation flow
498
  if generate_now and not st.session_state.get("busy"):
499
  if not st.session_state.get("videos"):
500
  st.error("No video loaded. Use 'Load Video' in the sidebar.")
 
511
  except Exception:
512
  pass
513
 
514
+ model_id = (st.session_state.get("model_input") or st.session_state.get("preferred_model") or "gemini-2.5-flash-lite").strip()
515
  if st.session_state.get("last_model") != model_id:
516
  st.session_state["last_model"] = ""
517
  maybe_create_agent(model_id)
 
534
  upload_path, compressed = compress_video_if_large(local_path)
535
 
536
  with st.spinner(f"Uploading video{' (compressed)' if compressed else ''}..."):
537
+ upload_progress_placeholder = st.empty()
 
 
538
  try:
539
  uploaded = upload_video_sdk(upload_path)
540
  except Exception as e:
 
543
  raise
544
 
545
  try:
 
546
  processing_placeholder = st.empty()
547
  processing_bar = processing_placeholder.progress(0)
548
+ def processing_cb(pct, elapsed, state):
549
+ try:
550
+ processing_bar.progress(min(100, int(pct)))
551
+ processing_placeholder.caption(f"State: {state} — elapsed: {elapsed}s")
552
+ except Exception:
553
+ pass
554
+ processed = wait_for_processed(uploaded, timeout=st.session_state.get("processing_timeout", 900), progress_callback=processing_cb)
555
  processing_bar.progress(100)
556
  processing_placeholder.success("Processing complete")
557
  except Exception as e:
 
570
  max_tokens = 2048 if "2.5" in model_used else 1024
571
  est_tokens = max_tokens
572
 
 
573
  agent = maybe_create_agent(model_used)
574
  debug_info = {"agent_attempted": False, "agent_ok": False, "agent_error": None, "agent_response_has_text": False}
575
  if agent:
 
601
 
602
  if not out:
603
  try:
604
+ gen_progress_placeholder = st.empty()
605
+ gen_status = gen_progress_placeholder.text("Starting generation...")
606
+ start_gen = time.time()
607
+ def gen_progress_cb(stage, elapsed, info):
608
+ try:
609
+ gen_status.text(f"Stage: {stage} — elapsed: {elapsed}s — {info}")
610
+ except Exception:
611
+ pass
612
+ out = generate_via_responses_api(prompt_text, processed, model_used, max_tokens=max_tokens, timeout=st.session_state.get("generation_timeout", 300), progress_callback=gen_progress_cb)
613
+ gen_progress_placeholder.text(f"Generation complete in {int(time.time()-start_gen)}s")
614
  except Exception as e:
615
  tb = traceback.format_exc()
616
  st.session_state["last_error"] = f"Responses API error: {e}\n\nDebug: {debug_info}\n\nTraceback:\n{tb}"