File size: 57,872 Bytes
a5784e9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
# --- browser_utils/operations.py ---
# Browser page operation functional module

import asyncio
import json
import logging
import os
import time
from typing import Any, Callable, Dict, Optional

from playwright.async_api import (
    Error as PlaywrightAsyncError,
)
from playwright.async_api import (
    Locator,
)
from playwright.async_api import (
    Page as AsyncPage,
)

# Import config and models
from config import (
    CHAT_SESSION_CONTENT_SELECTOR,
    CLICK_TIMEOUT_MS,
    DEBUG_LOGS_ENABLED,
    ERROR_TOAST_SELECTOR,
    INITIAL_WAIT_MS_BEFORE_POLLING,
    LAST_CHAT_TURN_SELECTOR,
    MODELS_ENDPOINT_URL_CONTAINS,
    QUOTA_EXCEEDED_SELECTOR,
    SCROLL_CONTAINER_SELECTOR,
)
from config.global_state import GlobalState
from models import ClientDisconnectedError, QuotaExceededError

logger = logging.getLogger("AIStudioProxyServer")


async def check_quota_limit(page: AsyncPage, req_id: str) -> None:
    """Check for blocking quota errors immediately."""
    # 1. Check Global State first
    if GlobalState.IS_QUOTA_EXCEEDED:
        raise QuotaExceededError("Global Quota Exceeded Flag is Active.")

    try:
        # 2. Check UI for Quota Error (New Selector)
        if await page.locator(QUOTA_EXCEEDED_SELECTOR).count() > 0:
            element = page.locator(QUOTA_EXCEEDED_SELECTOR).first
            if await element.is_visible(timeout=500):
                text = await element.text_content()
                if text and "user has exceeded quota" in text.lower():
                    logger.critical(
                        f"[{req_id}] ❌ Quota Limit Detected via UI! Text: {text}"
                    )
                    GlobalState.set_quota_exceeded(message=text)
                    raise QuotaExceededError(f"Quota exceeded detected via UI: {text}")

        # 3. Check UI for Quota Error (Old Selector - Legacy Fallback)
        quota_selector = (
            'ms-callout.warning-callout:has-text("You are out of free generations")'
        )
        if await page.locator(quota_selector).count() > 0:
            if await page.locator(quota_selector).first.is_visible(timeout=500):
                logger.critical(
                    f"[{req_id}] ❌ Quota Limit Detected (Legacy)! Account is out of free generations."
                )
                GlobalState.set_quota_exceeded(
                    message="AI Studio Account is out of free generations"
                )
                raise QuotaExceededError(
                    "AI Studio Account is out of free generations."
                )

    except QuotaExceededError:
        raise
    except Exception as e:
        # Don't let check errors block the main flow, unless it's the quota error itself
        logger.warning(f"[{req_id}] Error checking for quota limit: {e}")


async def get_raw_text_content(
    response_element: Locator, previous_text: str, req_id: str
) -> str:
    """Get raw text content from response element"""
    raw_text = previous_text
    try:
        await response_element.wait_for(state="attached", timeout=1000)

        # [FIX-SELECTOR] Ensure element is in viewport for DOM virtualization
        try:
            await response_element.scroll_into_view_if_needed(timeout=1000)
        except Exception:
            pass

        pre_element = response_element.locator("pre").last
        pre_found_and_visible = False
        try:
            await pre_element.wait_for(state="visible", timeout=250)
            pre_found_and_visible = True
        except PlaywrightAsyncError:
            pass

        if pre_found_and_visible:
            try:
                # [FIX-SELECTOR] Ensure pre element is in viewport
                await pre_element.scroll_into_view_if_needed(timeout=500)
                raw_text = await pre_element.inner_text(timeout=500)
            except PlaywrightAsyncError as pre_err:
                if DEBUG_LOGS_ENABLED:
                    logger.debug(
                        f"[{req_id}] (GetRawText) Failed to get inner text of pre element: {pre_err}"
                    )
        else:
            try:
                # [FIX-SELECTOR] Ensure response element is in viewport
                await response_element.scroll_into_view_if_needed(timeout=500)
                raw_text = await response_element.inner_text(timeout=500)
            except PlaywrightAsyncError as e_parent:
                if DEBUG_LOGS_ENABLED:
                    logger.debug(
                        f"[{req_id}] (GetRawText) Failed to get inner text of response element: {e_parent}"
                    )
    except PlaywrightAsyncError as e_parent:
        if DEBUG_LOGS_ENABLED:
            logger.debug(
                f"[{req_id}] (GetRawText) Response element not ready: {e_parent}"
            )
    except Exception as e_unexpected:
        logger.warning(f"[{req_id}] (GetRawText) Unexpected error: {e_unexpected}")

    if raw_text != previous_text:
        if DEBUG_LOGS_ENABLED:
            preview = raw_text[:100].replace("\n", "\\n")
            logger.debug(
                f"[{req_id}] (GetRawText) Text updated, length: {len(raw_text)}, Preview: '{preview}...'"
            )
    return raw_text


async def _handle_model_list_response(response: Any):
    """Handle model list response"""
    # Need access to global variables
    from api_utils.server_state import state

    global_model_list_raw_json = state.global_model_list_raw_json  # noqa: F841
    parsed_model_list = state.parsed_model_list  # noqa: F841
    model_list_fetch_event = state.model_list_fetch_event
    excluded_model_ids = state.excluded_model_ids

    if MODELS_ENDPOINT_URL_CONTAINS in response.url and response.ok:
        # Check if in login flow
        launch_mode = os.environ.get("LAUNCH_MODE", "debug")
        is_in_login_flow = launch_mode in ["debug"] and not state.is_page_ready

        if is_in_login_flow:
            # Silent during login flow
            pass
        else:
            logger.info(
                f"Captured potential model list response from: {response.url} (Status: {response.status})"
            )
        try:
            # Fix: Handle Network.getResponseBody protocol error by using fallback methods
            try:
                data = await response.json()
            except Exception as protocol_err:
                if "Network.getResponseBody" in str(
                    protocol_err
                ) or "Protocol error" in str(protocol_err):
                    logger.warning(
                        f"Playwright Protocol Error detected in model list response: {protocol_err}"
                    )
                    # Fallback: Try to get response body text and parse manually
                    try:
                        response_text = await response.text()
                        data = json.loads(response_text)
                        logger.info(
                            "Successfully parsed model list response using fallback method"
                        )
                    except Exception as fallback_err:
                        logger.error(
                            f"Fallback parsing also failed for model list response: {fallback_err}"
                        )
                        if (
                            model_list_fetch_event
                            and not model_list_fetch_event.is_set()
                        ):
                            model_list_fetch_event.set()
                        return
                else:
                    raise  # Re-raise if it's not the specific protocol error we're handling
            models_array_container = None
            if isinstance(data, list) and data:
                if (
                    isinstance(data[0], list)
                    and data[0]
                    and isinstance(data[0][0], list)
                ):
                    if not is_in_login_flow:
                        logger.info(
                            "Detected three-level list structure data[0][0] is list. Setting models_array_container to data[0]."
                        )
                    models_array_container = data[0]
                elif (
                    isinstance(data[0], list)
                    and data[0]
                    and isinstance(data[0][0], str)
                ):
                    if not is_in_login_flow:
                        logger.info(
                            "Detected two-level list structure data[0][0] is str. Setting models_array_container to data."
                        )
                    models_array_container = data
                elif isinstance(data[0], dict):
                    if not is_in_login_flow:
                        logger.info(
                            "Detected root list with dictionaries. Using data directly as models_array_container."
                        )
                    models_array_container = data
                else:
                    logger.warning(
                        f"Unknown list nested structure. data[0] type: {type(data[0]) if data else 'N/A'}. data[0] Preview: {str(data[0])[:200] if data else 'N/A'}"
                    )
            elif isinstance(data, dict):
                if "data" in data and isinstance(data["data"], list):
                    models_array_container = data["data"]
                elif "models" in data and isinstance(data["models"], list):
                    models_array_container = data["models"]
                else:
                    for key, value in data.items():
                        if (
                            isinstance(value, list)
                            and len(value) > 0
                            and isinstance(value[0], (dict, list))
                        ):
                            models_array_container = value
                            logger.info(
                                f"Model list data found under '{key}' key via heuristic search."
                            )
                            break
                    if models_array_container is None:
                        logger.warning(
                            "Could not automatically locate model list array in dictionary response."
                        )
                        if (
                            model_list_fetch_event
                            and not model_list_fetch_event.is_set()
                        ):
                            model_list_fetch_event.set()
                        return
            else:
                logger.warning(
                    f"Received model list data is neither list nor dictionary: {type(data)}"
                )
                if model_list_fetch_event and not model_list_fetch_event.is_set():
                    model_list_fetch_event.set()
                return

            if models_array_container is not None:
                new_parsed_list = []
                for entry_in_container in models_array_container:
                    model_fields_list = None
                    if isinstance(entry_in_container, dict):
                        potential_id = entry_in_container.get(
                            "id",
                            entry_in_container.get(
                                "model_id", entry_in_container.get("modelId")
                            ),
                        )
                        if potential_id:
                            model_fields_list = entry_in_container
                        else:
                            model_fields_list = list(entry_in_container.values())
                    elif isinstance(entry_in_container, list):
                        model_fields_list = entry_in_container
                    else:
                        logger.debug(
                            f"Skipping entry of unknown type: {type(entry_in_container)}"
                        )
                        continue

                    if not model_fields_list:
                        logger.debug(
                            "Skipping entry because model_fields_list is empty or None."
                        )
                        continue

                    model_id_path_str = None
                    display_name_candidate = ""
                    description_candidate = "N/A"
                    default_max_output_tokens_val = None
                    default_top_p_val = None
                    default_temperature_val = 1.0
                    supported_max_output_tokens_val = None
                    current_model_id_for_log = "UnknownModelYet"

                    try:
                        if isinstance(model_fields_list, list):
                            if not (
                                len(model_fields_list) > 0
                                and isinstance(model_fields_list[0], (str, int, float))
                            ):
                                logger.debug(
                                    f"Skipping list-based model_fields due to invalid first element: {str(model_fields_list)[:100]}"
                                )
                                continue
                            model_id_path_str = str(model_fields_list[0])
                            current_model_id_for_log = (
                                model_id_path_str.split("/")[-1]
                                if model_id_path_str and "/" in model_id_path_str
                                else model_id_path_str
                            )
                            display_name_candidate = (
                                str(model_fields_list[3])
                                if len(model_fields_list) > 3
                                else ""
                            )
                            description_candidate = (
                                str(model_fields_list[4])
                                if len(model_fields_list) > 4
                                else "N/A"
                            )

                            if (
                                len(model_fields_list) > 6
                                and model_fields_list[6] is not None
                            ):
                                try:
                                    val_int = int(model_fields_list[6])
                                    default_max_output_tokens_val = val_int
                                    supported_max_output_tokens_val = val_int
                                except (ValueError, TypeError):
                                    logger.warning(
                                        f"Model {current_model_id_for_log}: Could not parse value at index 6 '{model_fields_list[6]}' as max_output_tokens."
                                    )

                            if (
                                len(model_fields_list) > 9
                                and model_fields_list[9] is not None
                            ):
                                try:
                                    raw_top_p = float(model_fields_list[9])
                                    if not (0.0 <= raw_top_p <= 1.0):
                                        logger.warning(
                                            f"Model {current_model_id_for_log}: raw top_p value {raw_top_p} (from index 9) out of [0,1] range, will clip."
                                        )
                                        default_top_p_val = max(
                                            0.0, min(1.0, raw_top_p)
                                        )
                                    else:
                                        default_top_p_val = raw_top_p
                                except (ValueError, TypeError):
                                    logger.warning(
                                        f"Model {current_model_id_for_log}: Could not parse value at index 9 '{model_fields_list[9]}' as top_p."
                                    )

                        elif isinstance(model_fields_list, dict):
                            model_id_path_str = str(
                                model_fields_list.get(
                                    "id",
                                    model_fields_list.get(
                                        "model_id", model_fields_list.get("modelId")
                                    ),
                                )
                            )
                            current_model_id_for_log = (
                                model_id_path_str.split("/")[-1]
                                if model_id_path_str and "/" in model_id_path_str
                                else model_id_path_str
                            )
                            display_name_candidate = str(
                                model_fields_list.get(
                                    "displayName",
                                    model_fields_list.get(
                                        "display_name",
                                        model_fields_list.get("name", ""),
                                    ),
                                )
                            )
                            description_candidate = str(
                                model_fields_list.get("description", "N/A")
                            )

                            mot_parsed = model_fields_list.get(
                                "maxOutputTokens",
                                model_fields_list.get(
                                    "defaultMaxOutputTokens",
                                    model_fields_list.get("outputTokenLimit"),
                                ),
                            )
                            if mot_parsed is not None:
                                try:
                                    val_int = int(mot_parsed)
                                    default_max_output_tokens_val = val_int
                                    supported_max_output_tokens_val = val_int
                                except (ValueError, TypeError):
                                    logger.warning(
                                        f"Model {current_model_id_for_log}: Could not parse dict value '{mot_parsed}' as max_output_tokens."
                                    )

                            top_p_parsed = model_fields_list.get(
                                "topP", model_fields_list.get("defaultTopP")
                            )
                            if top_p_parsed is not None:
                                try:
                                    raw_top_p = float(top_p_parsed)
                                    if not (0.0 <= raw_top_p <= 1.0):
                                        logger.warning(
                                            f"Model {current_model_id_for_log}: raw top_p value {raw_top_p} (from dict) out of [0,1] range, will clip."
                                        )
                                        default_top_p_val = max(
                                            0.0, min(1.0, raw_top_p)
                                        )
                                    else:
                                        default_top_p_val = raw_top_p
                                except (ValueError, TypeError):
                                    logger.warning(
                                        f"Model {current_model_id_for_log}: Could not parse dict value '{top_p_parsed}' as top_p."
                                    )

                            temp_parsed = model_fields_list.get(
                                "temperature",
                                model_fields_list.get("defaultTemperature"),
                            )
                            if temp_parsed is not None:
                                try:
                                    default_temperature_val = float(temp_parsed)
                                except (ValueError, TypeError):
                                    logger.warning(
                                        f"Model {current_model_id_for_log}: Could not parse dict value '{temp_parsed}' as temperature."
                                    )
                        else:
                            logger.debug(
                                f"Skipping entry because model_fields_list is not list or dict: {type(model_fields_list)}"
                            )
                            continue
                    except Exception as e_parse_fields:
                        logger.error(
                            f"Error parsing model fields for entry {str(entry_in_container)[:100]}: {e_parse_fields}"
                        )
                        continue

                    if model_id_path_str and model_id_path_str.lower() != "none":
                        simple_model_id_str = (
                            model_id_path_str.split("/")[-1]
                            if "/" in model_id_path_str
                            else model_id_path_str
                        )
                        if simple_model_id_str in excluded_model_ids:
                            if not is_in_login_flow:
                                logger.info(
                                    f"Model '{simple_model_id_str}' is in excluded_model_ids list, skipped."
                                )
                            continue

                        final_display_name_str = (
                            display_name_candidate
                            if display_name_candidate
                            else simple_model_id_str.replace("-", " ").title()
                        )
                        model_entry_dict = {
                            "id": simple_model_id_str,
                            "object": "model",
                            "created": int(time.time()),
                            "owned_by": "ai_studio",
                            "display_name": final_display_name_str,
                            "description": description_candidate,
                            "raw_model_path": model_id_path_str,
                            "default_temperature": default_temperature_val,
                            "default_max_output_tokens": default_max_output_tokens_val,
                            "supported_max_output_tokens": supported_max_output_tokens_val,
                            "default_top_p": default_top_p_val,
                        }
                        new_parsed_list.append(model_entry_dict)
                    else:
                        logger.debug(
                            f"Skipping entry due to invalid model_id_path: {model_id_path_str} from entry {str(entry_in_container)[:100]}"
                        )

                if new_parsed_list:
                    # Check if already has network intercepted injected models
                    has_network_injected_models = False
                    if models_array_container:
                        for entry_in_container in models_array_container:
                            if (
                                isinstance(entry_in_container, list)
                                and len(entry_in_container) > 10
                            ):
                                # Check for network injection marker
                                if "__NETWORK_INJECTED__" in entry_in_container:
                                    has_network_injected_models = True
                                    break

                    if has_network_injected_models and not is_in_login_flow:
                        logger.info("Detected network interception injected models")

                    # Note: No longer add injected models in backend
                    # Only rely on network interception injection

                    state.parsed_model_list = sorted(
                        new_parsed_list, key=lambda m: m.get("display_name", "").lower()
                    )
                    state.global_model_list_raw_json = json.dumps(
                        {"data": state.parsed_model_list, "object": "list"}
                    )
                    if DEBUG_LOGS_ENABLED:
                        log_output = f"Successfully parsed and updated model list. Total parsed models: {len(state.parsed_model_list)}.\n"
                        for i, item in enumerate(
                            state.parsed_model_list[
                                : min(3, len(state.parsed_model_list))
                            ]
                        ):
                            log_output += f"  Model {i + 1}: ID={item.get('id')}, Name={item.get('display_name')}, Temp={item.get('default_temperature')}, MaxTokDef={item.get('default_max_output_tokens')}, MaxTokSup={item.get('supported_max_output_tokens')}, TopP={item.get('default_top_p')}\n"
                        logger.info(log_output)
                    if model_list_fetch_event and not model_list_fetch_event.is_set():
                        model_list_fetch_event.set()
                elif not state.parsed_model_list:
                    logger.warning("Model list remains empty after parsing.")
                    if model_list_fetch_event and not model_list_fetch_event.is_set():
                        model_list_fetch_event.set()
            else:
                logger.warning(
                    "models_array_container is None, cannot parse model list."
                )
                if model_list_fetch_event and not model_list_fetch_event.is_set():
                    model_list_fetch_event.set()
        except json.JSONDecodeError as json_err:
            logger.error(
                f"Failed to parse model list JSON: {json_err}. Response (first 500 chars): {await response.text()[:500]}"
            )
        except Exception as e_handle_list_resp:
            logger.exception(
                f"Unexpected error handling model list response: {e_handle_list_resp}"
            )
        finally:
            if model_list_fetch_event and not model_list_fetch_event.is_set():
                logger.info(
                    "Model list response handling finished, forcing model_list_fetch_event set."
                )
                model_list_fetch_event.set()


async def detect_and_extract_page_error(page: AsyncPage, req_id: str) -> Optional[str]:
    """Detect and extract page error"""
    error_toast_locator = page.locator(ERROR_TOAST_SELECTOR).last
    try:
        await error_toast_locator.wait_for(state="visible", timeout=500)
        message_locator = error_toast_locator.locator("span.content-text")
        error_message = await message_locator.text_content(timeout=500)
        if error_message:
            logger.error(
                f"[{req_id}]    Detected and extracted error message: {error_message}"
            )
            return error_message.strip()
        else:
            logger.warning(
                f"[{req_id}]    Detected error toast but failed to extract message."
            )
            return "Detected error toast but failed to extract specific message."
    except PlaywrightAsyncError:
        return None
    except Exception as e:
        logger.warning(f"[{req_id}]    Error checking for page error: {e}")
        return None


async def save_error_snapshot(
    error_name: str = "error", extra_context: Optional[Dict[str, Any]] = None
):
    """Save error snapshot - enhanced version with extra context support

    Args:
        error_name: Error name used for filename generation
        extra_context: Extra context info to be saved as JSON file
    """
    from api_utils.server_state import state

    name_parts = error_name.split("_")
    req_id = (
        name_parts[-1] if len(name_parts) > 1 and len(name_parts[-1]) == 7 else None
    )
    base_error_name = error_name if not req_id else "_".join(name_parts[:-1])
    log_prefix = f"[{req_id}]" if req_id else "[No ReqID]"
    page_to_snapshot = state.page_instance

    if (
        not state.browser_instance
        or not state.browser_instance.is_connected()
        or not page_to_snapshot
        or page_to_snapshot.is_closed()
    ):
        logger.warning(
            f"{log_prefix} Cannot save snapshot ({base_error_name}), browser/page unavailable."
        )
        return

    logger.info(
        f"{log_prefix} Attempting to save error snapshot ({base_error_name})..."
    )
    timestamp = int(time.time() * 1000)
    error_dir = os.path.join(os.path.dirname(__file__), "..", "errors_py")

    try:
        os.makedirs(error_dir, exist_ok=True)
        filename_suffix = f"{req_id}_{timestamp}" if req_id else f"{timestamp}"
        filename_base = f"{base_error_name}_{filename_suffix}"
        screenshot_path = os.path.join(error_dir, f"{filename_base}.png")
        html_path = os.path.join(error_dir, f"{filename_base}.html")
        context_path = os.path.join(error_dir, f"{filename_base}_context.json")

        # Save screenshot
        try:
            await page_to_snapshot.screenshot(
                path=screenshot_path, full_page=True, timeout=15000
            )
            logger.info(f"{log_prefix}   Snapshot saved to: {screenshot_path}")
        except Exception as ss_err:
            logger.error(
                f"{log_prefix}   Failed to save screenshot ({base_error_name}): {ss_err}"
            )

        # Save HTML content
        try:
            content = await page_to_snapshot.content()
            f = None
            try:
                f = open(html_path, "w", encoding="utf-8")
                f.write(content)
                logger.info(f"{log_prefix}   HTML saved to: {html_path}")
            except Exception as write_err:
                logger.error(
                    f"{log_prefix}   Failed to save HTML ({base_error_name}): {write_err}"
                )
            finally:
                if f:
                    try:
                        f.close()
                        logger.debug(f"{log_prefix}   HTML file closed correctly")
                    except Exception as close_err:
                        logger.error(
                            f"{log_prefix}   Error closing HTML file: {close_err}"
                        )
        except Exception as html_err:
            logger.error(
                f"{log_prefix}   Failed to get page content ({base_error_name}): {html_err}"
            )

        # Save extra context info
        if extra_context:
            try:
                context_data = {
                    "timestamp": timestamp,
                    "error_name": base_error_name,
                    "req_id": req_id,
                    "context": extra_context,
                    "page_url": page_to_snapshot.url if page_to_snapshot else "N/A",
                    "user_agent": await page_to_snapshot.evaluate("navigator.userAgent")
                    if page_to_snapshot
                    else "N/A",
                }
                with open(context_path, "w", encoding="utf-8") as f:
                    json.dump(context_data, f, indent=2, ensure_ascii=False)
                logger.info(f"{log_prefix}   Context info saved to: {context_path}")
            except Exception as context_err:
                logger.error(
                    f"{log_prefix}   Failed to save context info ({base_error_name}): {context_err}"
                )

    except Exception as dir_err:
        logger.error(
            f"{log_prefix}   Error creating error directory or saving snapshot ({base_error_name}): {dir_err}"
        )


async def capture_response_state_for_debug(
    req_id: str, captured_content: str = "", detection_method: str = ""
) -> Dict[str, Any]:
    """Capture response state for debug - dedicated for analysis of response integrity issues"""
    from api_utils.server_state import state

    page = state.page_instance

    if not page or page.is_closed():
        return {"error": "Page not available"}

    debug_info = {
        "req_id": req_id,
        "timestamp": int(time.time() * 1000),
        "detection_method": detection_method,
        "captured_content_length": len(captured_content) if captured_content else 0,
        "captured_content_preview": captured_content[:200] + "..."
        if len(captured_content) > 200
        else captured_content,
        "page_url": page.url,
        "thinking_blocks_found": [],
        "response_blocks_found": [],
        "generation_status": {},
        "ui_elements": {},
    }

    try:
        # Check Thinking blocks
        from config.selectors import (
            FINAL_RESPONSE_SELECTOR,
            GENERATION_STATUS_SELECTOR,
            THINKING_CONTAINER_SELECTOR,
        )

        # Find Thinking containers
        thinking_containers = await page.locator(THINKING_CONTAINER_SELECTOR).all()
        for i, container in enumerate(thinking_containers):
            try:
                is_visible = await container.is_visible(timeout=1000)
                text_content = (
                    await container.inner_text(timeout=1000) if is_visible else ""
                )
                debug_info["thinking_blocks_found"].append(
                    {
                        "index": i,
                        "visible": is_visible,
                        "text_length": len(text_content),
                        "text_preview": text_content[:100] + "..."
                        if len(text_content) > 100
                        else text_content,
                    }
                )
            except Exception as e:
                debug_info["thinking_blocks_found"].append(
                    {"index": i, "error": str(e)}
                )

        # Find final response blocks
        response_elements = await page.locator(FINAL_RESPONSE_SELECTOR).all()
        for i, elem in enumerate(response_elements):
            try:
                is_visible = await elem.is_visible(timeout=1000)
                text_content = await elem.inner_text(timeout=1000) if is_visible else ""
                debug_info["response_blocks_found"].append(
                    {
                        "index": i,
                        "visible": is_visible,
                        "text_length": len(text_content),
                        "text_preview": text_content[:100] + "..."
                        if len(text_content) > 100
                        else text_content,
                    }
                )
            except Exception as e:
                debug_info["response_blocks_found"].append(
                    {"index": i, "error": str(e)}
                )

        # Check generation status
        generation_elements = await page.locator(GENERATION_STATUS_SELECTOR).all()
        for i, elem in enumerate(generation_elements):
            try:
                is_visible = await elem.is_visible(timeout=1000)
                aria_label = (
                    await elem.get_attribute("aria-label") if is_visible else ""
                )
                debug_info["generation_status"][f"status_{i}"] = {
                    "visible": is_visible,
                    "aria_label": aria_label,
                }
            except Exception as e:
                debug_info["generation_status"][f"status_{i}"] = {"error": str(e)}

        # Check key UI elements
        from config.selectors import (
            INPUT_SELECTOR,
            REGENERATE_BUTTON_SELECTOR,
            SUBMIT_BUTTON_SELECTOR,
        )

        key_elements = {
            "input_field": INPUT_SELECTOR,
            "submit_button": SUBMIT_BUTTON_SELECTOR,
            "regenerate_button": REGENERATE_BUTTON_SELECTOR,
        }

        for name, selector in key_elements.items():
            try:
                elem = page.locator(selector)
                is_visible = await elem.is_visible(timeout=1000)
                is_disabled = (
                    await elem.is_disabled(timeout=1000) if is_visible else False
                )
                debug_info["ui_elements"][name] = {
                    "visible": is_visible,
                    "disabled": is_disabled,
                }
            except Exception as e:
                debug_info["ui_elements"][name] = {"error": str(e)}

    except Exception as e:
        debug_info["capture_error"] = str(e)

    return debug_info


async def get_response_via_edit_button(
    page: AsyncPage, req_id: str, check_client_disconnected: Callable
) -> Optional[str]:
    """Get response via edit button"""
    logger.info(f"[{req_id}] (Helper) Attempting to get response via edit button...")
    last_message_container = page.locator("ms-chat-turn").last
    edit_button = last_message_container.get_by_label("Edit")
    finish_edit_button = last_message_container.get_by_label("Stop editing")
    autosize_textarea_locator = last_message_container.locator("ms-autosize-textarea")
    actual_textarea_locator = autosize_textarea_locator.locator("textarea")

    try:
        logger.info(
            f"[{req_id}]   - Attempting to hover last message to show 'Edit' button..."
        )
        try:
            # Perform hover on message container
            await last_message_container.hover(timeout=CLICK_TIMEOUT_MS / 2)
            await asyncio.sleep(0.3)  # Wait for hover effect
            check_client_disconnected("Edit Response - after hover: ")
        except (ClientDisconnectedError, asyncio.CancelledError):
            raise
        except Exception as hover_err:
            logger.warning(
                f"[{req_id}]   - (get_response_via_edit_button) Hover last message failed (ignoring): {type(hover_err).__name__}"
            )

        logger.info(f"[{req_id}]   - Locating and clicking 'Edit' button...")
        try:
            from playwright.async_api import expect as expect_async

            await expect_async(edit_button).to_be_visible(timeout=CLICK_TIMEOUT_MS)
            check_client_disconnected("Edit Response - 'Edit' button visible after: ")
            await edit_button.click(timeout=CLICK_TIMEOUT_MS)
            logger.info(f"[{req_id}]   - 'Edit' button clicked.")
        except (ClientDisconnectedError, asyncio.CancelledError):
            raise
        except Exception as edit_btn_err:
            logger.error(
                f"[{req_id}]   - 'Edit' button not visible or click failed: {edit_btn_err}"
            )
            await save_error_snapshot(f"edit_response_edit_button_failed_{req_id}")
            return None

        check_client_disconnected("Edit Response - after clicking 'Edit' button: ")
        await asyncio.sleep(0.3)
        check_client_disconnected(
            "Edit Response - after delay following 'Edit' button click: "
        )

        logger.info(f"[{req_id}]   - Getting content from textarea...")
        response_content = None
        textarea_failed = False

        try:
            await expect_async(autosize_textarea_locator).to_be_visible(
                timeout=CLICK_TIMEOUT_MS
            )
            check_client_disconnected(
                "Edit Response - after autosize-textarea visible: "
            )

            try:
                data_value_content = await autosize_textarea_locator.get_attribute(
                    "data-value"
                )
                check_client_disconnected(
                    "Edit Response - after get_attribute data-value: "
                )
                if data_value_content is not None:
                    response_content = str(data_value_content)
                    logger.info(
                        f"[{req_id}]   - Successfully obtained content from data-value."
                    )
            except Exception as data_val_err:
                logger.warning(
                    f"[{req_id}]   - Failed to get data-value: {data_val_err}"
                )
                check_client_disconnected(
                    "Edit Response - after get_attribute data-value error: "
                )

            if response_content is None:
                logger.info(
                    f"[{req_id}]   - data-value failed or None, attempting to get input_value from internal textarea..."
                )
                try:
                    await expect_async(actual_textarea_locator).to_be_visible(
                        timeout=CLICK_TIMEOUT_MS / 2
                    )
                    input_val_content = await actual_textarea_locator.input_value(
                        timeout=CLICK_TIMEOUT_MS / 2
                    )
                    check_client_disconnected("Edit Response - after input_value: ")
                    if input_val_content is not None:
                        response_content = str(input_val_content)
                        logger.info(
                            f"[{req_id}]   - Successfully obtained content from input_value."
                        )
                except Exception as input_val_err:
                    logger.warning(
                        f"[{req_id}]   - Failed to get input_value as well: {input_val_err}"
                    )
                    check_client_disconnected(
                        "Edit Response - after input_value error: "
                    )

            if response_content is not None:
                response_content = response_content.strip()
                content_preview = response_content[:100].replace("\\n", "\\\\n")
                logger.info(
                    f"[{req_id}]   - ✅ Final content obtained (length={len(response_content)}): '{content_preview}...'"
                )
            else:
                logger.warning(
                    f"[{req_id}]   - All content retrieval methods (data-value, input_value) failed or returned None."
                )
                textarea_failed = True

        except Exception as textarea_err:
            logger.error(
                f"[{req_id}]   - Failed to locate or process textarea: {textarea_err}"
            )
            textarea_failed = True
            response_content = None
            check_client_disconnected("Edit Response - after textarea error: ")

        if not textarea_failed:
            logger.info(
                f"[{req_id}]   - Locating and clicking 'Stop editing' button..."
            )
            try:
                await expect_async(finish_edit_button).to_be_visible(
                    timeout=CLICK_TIMEOUT_MS
                )
                check_client_disconnected(
                    "Edit Response - 'Stop editing' button visible after: "
                )
                await finish_edit_button.click(timeout=CLICK_TIMEOUT_MS)
                logger.info(f"[{req_id}]   - 'Stop editing' button clicked.")
            except Exception as finish_btn_err:
                logger.warning(
                    f"[{req_id}]   - 'Stop editing' button not visible or click failed: {finish_btn_err}"
                )
                await save_error_snapshot(
                    f"edit_response_finish_button_failed_{req_id}"
                )
            check_client_disconnected("Edit Response - after clicking 'Stop editing': ")
            await asyncio.sleep(0.2)
            check_client_disconnected(
                "Edit Response - after delay following 'Stop editing' click: "
            )
        else:
            logger.info(
                f"[{req_id}]   - Skipping 'Stop editing' button click due to textarea read failure."
            )

        return response_content

    except ClientDisconnectedError:
        logger.info(f"[{req_id}] (Helper Edit) Client disconnected.")
        raise
    except Exception:
        logger.exception(
            f"[{req_id}] Unexpected error during getting response via edit button"
        )
        await save_error_snapshot(f"edit_response_unexpected_error_{req_id}")
        return None


async def get_response_via_copy_button(
    page: AsyncPage, req_id: str, check_client_disconnected: Callable
) -> Optional[str]:
    """Get response via copy button"""
    logger.info(f"[{req_id}] (Helper) Attempting to get response via copy button...")
    last_message_container = page.locator("ms-chat-turn").last
    more_options_button = last_message_container.get_by_label("Open options")
    copy_markdown_button = page.get_by_role("menuitem", name="Copy markdown")

    try:
        logger.info(
            f"[{req_id}]   - Attempting to hover last message to show options..."
        )
        await last_message_container.hover(timeout=CLICK_TIMEOUT_MS)
        check_client_disconnected("Copy Response - after hover: ")
        await asyncio.sleep(0.5)
        check_client_disconnected("Copy Response - after hover delay: ")
        logger.info(f"[{req_id}]   - Hovered.")

        logger.info(f"[{req_id}]   - Locating and clicking 'More options' button...")
        try:
            from playwright.async_api import expect as expect_async

            await expect_async(more_options_button).to_be_visible(
                timeout=CLICK_TIMEOUT_MS
            )
            check_client_disconnected(
                "Copy Response - after more options button visible: "
            )
            await more_options_button.click(timeout=CLICK_TIMEOUT_MS)
            logger.info(f"[{req_id}]   - 'More options' clicked (via get_by_label).")
        except (ClientDisconnectedError, asyncio.CancelledError):
            raise
        except Exception as more_opts_err:
            logger.error(
                f"[{req_id}]   - 'More options' button (via get_by_label) not visible or click failed: {more_opts_err}"
            )
            await save_error_snapshot(f"copy_response_more_options_failed_{req_id}")
            return None

        check_client_disconnected("Copy Response - after clicking more options: ")
        await asyncio.sleep(0.5)
        check_client_disconnected(
            "Copy Response - after delay following more options click: "
        )

        logger.info(f"[{req_id}]   - Locating and clicking 'Copy markdown' button...")
        copy_success = False
        try:
            await expect_async(copy_markdown_button).to_be_visible(
                timeout=CLICK_TIMEOUT_MS
            )
            check_client_disconnected("Copy Response - after copy button visible: ")
            await copy_markdown_button.click(timeout=CLICK_TIMEOUT_MS, force=True)
            copy_success = True
            logger.info(f"[{req_id}]   - 'Copy markdown' clicked (via get_by_role).")
        except (ClientDisconnectedError, asyncio.CancelledError):
            raise
        except Exception as copy_err:
            logger.error(
                f"[{req_id}]   - 'Copy markdown' button (via get_by_role) click failed: {copy_err}"
            )
            await save_error_snapshot(f"copy_response_copy_button_failed_{req_id}")
            return None

        if not copy_success:
            logger.error(f"[{req_id}]   - Failed to click 'Copy markdown' button.")
            return None

        check_client_disconnected("Copy Response - after clicking copy button: ")
        await asyncio.sleep(0.5)
        check_client_disconnected(
            "Copy Response - after delay following copy button click: "
        )

        logger.info(f"[{req_id}]   - Reading clipboard content...")
        try:
            clipboard_content = await page.evaluate("navigator.clipboard.readText()")
            check_client_disconnected("Copy Response - after reading clipboard: ")
            if clipboard_content:
                content_preview = clipboard_content[:100].replace("\n", "\\\\n")
                logger.info(
                    f"[{req_id}]   - ✅ Successfully obtained clipboard content (length={len(clipboard_content)}): '{content_preview}...'"
                )
                return clipboard_content
            else:
                logger.error(f"[{req_id}]   - Clipboard content is empty.")
                return None
        except Exception as clipboard_err:
            if "clipboard-read" in str(clipboard_err):
                logger.error(
                    f"[{req_id}]   - Clipboard read failed: possible permissions issue. Error: {clipboard_err}"
                )
            else:
                logger.error(f"[{req_id}]   - Clipboard read failed: {clipboard_err}")
            await save_error_snapshot(f"copy_response_clipboard_read_failed_{req_id}")
            return None

    except ClientDisconnectedError:
        logger.info(f"[{req_id}] (Helper Copy) Client disconnected.")
        raise
    except Exception:
        logger.exception(f"[{req_id}] Unexpected error during copying response")
        await save_error_snapshot(f"copy_response_unexpected_error_{req_id}")
        return None


async def _wait_for_response_completion(
    page: AsyncPage,
    prompt_textarea_locator: Locator,
    submit_button_locator: Locator,
    edit_button_locator: Locator,
    req_id: str,
    check_client_disconnected_func: Callable,
    current_chat_id: Optional[str],
    prompt_length: int,
    initial_wait_ms=INITIAL_WAIT_MS_BEFORE_POLLING,
    timeout: Optional[float] = None,
) -> bool:
    """Wait for response completion"""
    from playwright.async_api import TimeoutError

    # [FIX-03] Dynamic TTFB Timeout - Rotation Aware
    if timeout is None:
        base_timeout_seconds = 5 + (prompt_length / 1000.0)

        # Rotation-aware adjustments
        if GlobalState.IS_QUOTA_EXCEEDED:
            rotation_overhead = 20  # 20 second overhead for rotation
            timeout_seconds = max(
                base_timeout_seconds + rotation_overhead, 30
            )  # Minimum 30s during rotation
            logger.info(
                f"[{req_id}] (WaitV3) Rotation detected - applying extended timeout: {timeout_seconds:.2f}s"
            )
        else:
            timeout_seconds = max(base_timeout_seconds, 10)
            timeout_seconds = min(timeout_seconds, 120)

            if prompt_length > 5000:
                timeout_seconds *= 1.5
                logger.info(
                    f"[{req_id}] (WaitV3) Large prompt detected - extending timeout by 50%: {timeout_seconds:.2f}s"
                )
    else:
        timeout_seconds = timeout
        if GlobalState.IS_QUOTA_EXCEEDED and timeout_seconds < 30:
            timeout_seconds = 30
            logger.info(
                f"[{req_id}] (WaitV3) Rotation detected - enforcing minimum timeout: {timeout_seconds:.2f}s"
            )

    _timeout_ms = timeout_seconds * 1000

    logger.info(
        f"[{req_id}] (WaitV3) Waiting for response completion... (Dynamic Timeout: {timeout_seconds:.2f}s)"
    )
    await asyncio.sleep(initial_wait_ms / 1000)

    start_time = time.time()

    from config import UI_GENERATION_WAIT_TIMEOUT_MS

    wait_timeout_ms_short = UI_GENERATION_WAIT_TIMEOUT_MS

    consecutive_empty_input_submit_disabled_count = 0

    current_timeout_seconds = timeout_seconds

    while True:
        # [FIX-SCROLL] Active Viewport Tracking (Auto-Scroll)
        try:
            await page.evaluate(
                """([scrollSel, contentSel, lastTurnSel]) => {
                const scrollContainer = document.querySelector(scrollSel);
                if (scrollContainer) {
                    scrollContainer.scrollTop = scrollContainer.scrollHeight;
                }

                const sessionContent = document.querySelector(contentSel);
                if (sessionContent) {
                     sessionContent.scrollTop = sessionContent.scrollHeight;
                }

                const lastTurn = document.querySelector(lastTurnSel);
                if (lastTurn) {
                    lastTurn.scrollIntoView({behavior: "instant", block: "end"});
                }

                window.scrollTo(0, document.body.scrollHeight);
            }""",
                [
                    SCROLL_CONTAINER_SELECTOR,
                    CHAT_SESSION_CONTENT_SELECTOR,
                    LAST_CHAT_TURN_SELECTOR,
                ],
            )
        except Exception:
            pass

        await check_quota_limit(page, req_id)

        try:
            check_client_disconnected_func("Wait for completion - loop start")
        except ClientDisconnectedError:
            logger.info(f"[{req_id}] (WaitV3) Client disconnected, aborting wait.")
            return False

        time_elapsed = time.time() - start_time
        if time_elapsed > current_timeout_seconds:
            is_thinking = await page.locator(
                'button[aria-label="Stop generating"]'
            ).is_visible()
            if is_thinking:
                logger.warning(
                    f"[{req_id}] 🚨 TIMEOUT REACHED despite active UI! Forcing stream completion."
                )
            else:
                logger.warning(
                    f"[{req_id}] ⏰ (WaitV3) Timed out waiting for response completion ({current_timeout_seconds:.1f}s). Aborting."
                )
            await save_error_snapshot(f"wait_completion_v3_overall_timeout_{req_id}")
            return False

        try:
            check_client_disconnected_func("Wait for completion - after timeout check")
        except ClientDisconnectedError:
            return False

        # C. Check if "Thinking" (UI is busy)
        stop_button_locator = page.locator('button[aria-label="Stop generating"]')
        is_thinking = await stop_button_locator.is_visible()
        if is_thinking:
            if DEBUG_LOGS_ENABLED:
                logger.debug(
                    f"[{req_id}] (WaitV3) UI shows thinking, but NOT resetting timeout (Network State Priority)"
                )

        # --- Primary conditions: Input empty & Submit disabled ---
        is_input_empty = await prompt_textarea_locator.input_value() == ""
        is_submit_disabled = False
        try:
            is_submit_disabled = await submit_button_locator.is_disabled(
                timeout=wait_timeout_ms_short
            )
        except TimeoutError:
            logger.warning(
                f"[{req_id}] (WaitV3) Timed out checking if submit button is disabled. Assuming not disabled for this check."
            )

        try:
            check_client_disconnected_func(
                "Wait for completion - after button status check"
            )
        except ClientDisconnectedError:
            return False

        if is_input_empty and is_submit_disabled:
            consecutive_empty_input_submit_disabled_count += 1
            if DEBUG_LOGS_ENABLED:
                logger.debug(
                    f"[{req_id}] (WaitV3) Primary conditions met: Input empty, submit disabled (count: {consecutive_empty_input_submit_disabled_count})."
                )

            # --- Final confirmation: Edit button visible ---
            try:
                if await edit_button_locator.is_visible(timeout=wait_timeout_ms_short):
                    logger.info(
                        f"[{req_id}] (WaitV3) ✅ Response complete: Input empty, submit disabled, edit button visible."
                    )
                    return True
            except TimeoutError:
                if DEBUG_LOGS_ENABLED:
                    logger.debug(
                        f"[{req_id}] (WaitV3) After primary conditions met, check for edit button visibility timed out."
                    )

            try:
                check_client_disconnected_func(
                    "Wait for completion - after edit button check"
                )
            except ClientDisconnectedError:
                return False

            # Heuristic completion: if primary conditions stay met but edit button doesn't appear
            if consecutive_empty_input_submit_disabled_count >= 3:
                logger.warning(
                    f"[{req_id}] (WaitV3) Response might be complete (heuristic): Input empty, submit disabled, but edit button still hasn't appeared after {consecutive_empty_input_submit_disabled_count} checks. Assuming complete."
                )
                return True
        else:
            consecutive_empty_input_submit_disabled_count = 0
            if DEBUG_LOGS_ENABLED:
                reasons = []
                if not is_input_empty:
                    reasons.append("input not empty")
                if not is_submit_disabled:
                    reasons.append("submit button not disabled")
                logger.debug(
                    f"[{req_id}] (WaitV3) Primary conditions not met ({', '.join(reasons)}). Continuing polling..."
                )

        await asyncio.sleep(0.5)


async def _get_final_response_content(
    page: AsyncPage, req_id: str, check_client_disconnected: Callable
) -> Optional[str]:
    """Get final response content"""
    logger.info(
        f"[{req_id}] (Helper GetContent) Starting to get final response content..."
    )
    response_content = await get_response_via_edit_button(
        page, req_id, check_client_disconnected
    )
    if response_content is not None:
        logger.info(
            f"[{req_id}] (Helper GetContent) ✅ Successfully obtained content via edit button."
        )
        return response_content

    logger.warning(
        f"[{req_id}] (Helper GetContent) Edit button method failed or returned empty, falling back to copy button method..."
    )
    response_content = await get_response_via_copy_button(
        page, req_id, check_client_disconnected
    )
    if response_content is not None:
        logger.info(
            f"[{req_id}] (Helper GetContent) ✅ Successfully obtained content via copy button."
        )
        return response_content

    logger.error(
        f"[{req_id}] (Helper GetContent) All response content retrieval methods failed."
    )
    await save_error_snapshot(f"get_content_all_methods_failed_{req_id}")
    return None