File size: 85,858 Bytes
4b9d59b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
import asyncio
from datetime import datetime
from enum import Enum
import hashlib
import json
import logging
import os
import re
import uuid
from typing import Any, AsyncGenerator, Dict, List, Optional

# Try imports for optional dependencies
try:
    from openai import AsyncOpenAI, OpenAI
except ImportError:
    OpenAI = None
    AsyncOpenAI = None

try:
    import instructor
    INSTRUCTOR_AVAILABLE = True
except ImportError:
    instructor = None
    INSTRUCTOR_AVAILABLE = False

# Core imports (moved from inline for better testability)
from core.benchmarks import get_quality_score, get_capability_score
from core.byok_endpoints import get_byok_manager
from core.cost_config import (
    BYOK_ENABLED_PLANS,
    MODEL_TIER_RESTRICTIONS,
    get_llm_cost)
from core.database import get_db_session
from core.dynamic_pricing_fetcher import (
    get_pricing_fetcher,
    refresh_pricing_cache)
from core.llm.cache_aware_router import CacheAwareRouter
from core.llm.cognitive_tier_service import CognitiveTierService
from core.llm.cognitive_tier_system import CognitiveTier, CognitiveClassifier
from core.llm_usage_tracker import llm_usage_tracker
from core.lux_config import lux_config
from core.models import GovernanceDocument, AgentExecution, Tenant, Workspace, ModelCatalog

logger = logging.getLogger(__name__)


class QueryComplexity(Enum):
    """Query complexity levels for cost-based routing"""
    SIMPLE = "simple"       # Short, straightforward queries -> cheapest provider
    MODERATE = "moderate"   # Medium complexity -> balanced provider
    COMPLEX = "complex"     # Multi-step reasoning -> quality provider
    ADVANCED = "advanced"   # Code, math, analysis -> specialized provider


# Provider tier mapping for cost optimization
PROVIDER_TIERS = {
    # Budget tier - cheapest, good for simple tasks
    "budget": ["deepseek", "moonshot", "glm"],
    # Mid tier - balanced cost/quality
    "mid": ["anthropic", "gemini", "mistral"],
    # Premium tier - best quality, higher cost
    "premium": ["openai", "anthropic"],
    # Specialized - task-specific
    "code": ["deepseek", "openai"],
    "math": ["deepseek", "openai"],
    "creative": ["anthropic", "openai"],
}

# Model recommendations per provider (2026 Frontier Refresh)
COST_EFFICIENT_MODELS = {
    "openai": {
        QueryComplexity.SIMPLE: "o4-mini",
        QueryComplexity.MODERATE: "o4-mini",
        QueryComplexity.COMPLEX: "o3-mini",
        QueryComplexity.ADVANCED: "o3",
    },
    "anthropic": {
        QueryComplexity.SIMPLE: "claude-3-haiku-20240307",
        QueryComplexity.MODERATE: "claude-3-haiku-20240307",
        QueryComplexity.COMPLEX: "claude-3-5-sonnet",
        QueryComplexity.ADVANCED: "claude-4-opus",
    },
    "deepseek": {
        QueryComplexity.SIMPLE: "deepseek-chat",
        QueryComplexity.MODERATE: "deepseek-chat",
        QueryComplexity.COMPLEX: "deepseek-v3.2",
        QueryComplexity.ADVANCED: "deepseek-v3.2-speciale", # User Feedback: Lower cost, frontier reasoning
    },
    "gemini": {
        QueryComplexity.SIMPLE: "gemini-3-flash",
        QueryComplexity.MODERATE: "gemini-3-flash",
        QueryComplexity.COMPLEX: "gemini-3-flash",
        QueryComplexity.ADVANCED: "gemini-3-pro",
    },
    "moonshot": {
        QueryComplexity.SIMPLE: "qwen-3-7b",
        QueryComplexity.MODERATE: "qwen-3-7b",
        QueryComplexity.COMPLEX: "qwen-3-max",
        QueryComplexity.ADVANCED: "qwen-3-max",
    },
    "minimax": {
        QueryComplexity.SIMPLE: "MiniMax-M2.7-highspeed",
        QueryComplexity.MODERATE: "MiniMax-M2.7-highspeed",
        QueryComplexity.COMPLEX: "MiniMax-M2.7",
        QueryComplexity.ADVANCED: "MiniMax-M2.7",
    },
    "lux": {  # LUX Computer Use (Claude 3.5 Sonnet based)
        QueryComplexity.SIMPLE: "lux-1.0",
        QueryComplexity.MODERATE: "lux-1.0",
        QueryComplexity.COMPLEX: "lux-1.0",
        QueryComplexity.ADVANCED: "lux-1.0",
    },
    "qwen": {
        QueryComplexity.SIMPLE: "qwen-plus",
        QueryComplexity.MODERATE: "qwen-plus",
        QueryComplexity.COMPLEX: "qwen-plus",
        QueryComplexity.ADVANCED: "qwen-max",
    },
}


# Models that do not support tool calling or agentic runtimes (Phase 6.6)
MODELS_WITHOUT_TOOLS = {
    "deepseek-v3.2-speciale",
}

# Minimum quality scores by CognitiveTier for model filtering
MIN_QUALITY_BY_TIER = {
    CognitiveTier.MICRO: 0,
    CognitiveTier.STANDARD: 80,
    CognitiveTier.VERSATILE: 86,
    CognitiveTier.HEAVY: 90,
    CognitiveTier.COMPLEX: 94,
}

# Phase 14.5: Coordinated Multimodal Reasoning
REASONING_MODELS_WITHOUT_VISION = {
    "deepseek-v3.2",
    "deepseek-v3.2-speciale",
    "o3",
    "o3-mini",
    "deepseek-chat",
    "MiniMax-M2.7"
}

VISION_ONLY_MODELS = {
    "janus-pro-7b",
    "janus-pro-1.3b",
}


class BYOKHandler:
    """
    Handler for LLM interactions using BYOK system with intelligent cost optimization.
    Automatically routes queries to the most cost-effective provider based on complexity.

    Phase 68-04: MiniMax M2.5 Integration
    - Positioned in STANDARD tier with estimated $1/M pricing
    - API access may be closed - graceful fallback to next provider
    - Quality score 88 (between gemini-2.0-flash @ 86 and deepseek-chat @ 80)
    - Native agent support, no prompt caching
    """
    def __init__(
        self,
        workspace_id: str = "default",
        tenant_id: str = "default",
        provider_id: str = "auto",
        cognitive_classifier: Optional[CognitiveClassifier] = None,
        cache_router: Optional[CacheAwareRouter] = None,
        db_session=None,
        tier_service: Optional[CognitiveTierService] = None
    ):
        self.workspace_id = workspace_id
        self.tenant_id = tenant_id
        self.default_provider_id = provider_id if provider_id != "auto" else None
        self.clients: Dict[str, Any] = {}
        self.async_clients: Dict[str, Any] = {}
        self.byok_manager = get_byok_manager()

        # Use injected dependencies or create defaults
        self.cognitive_classifier = cognitive_classifier or CognitiveClassifier()  # Phase 68: Cognitive tier system
        self._initialize_clients()

        # Initialize cache-aware router for cost optimization
        self.cache_router = cache_router or CacheAwareRouter(get_pricing_fetcher())

        # Phase 68-06: Initialize Cognitive Tier Service for orchestration
        if db_session is not None:
            self.db_session = db_session
        else:
            try:
                self.db_session = get_db_session().__enter__()  # Get session for service
            except Exception as e:
                logger.warning(f"Could not create database session for tier service: {e}")
                self.db_session = None
        self.tier_service = tier_service or CognitiveTierService(workspace_id, self.db_session, tenant_id=tenant_id)

        # Phase 226.4-04: Initialize excluded models cache
        self.excluded_models = set()
        self._refresh_excluded_cache()

        # Phase 226.4-04: Initialize health monitor
        from core.provider_health_monitor import get_provider_health_monitor
        self.health_monitor = get_provider_health_monitor()
        self.async_clients = self.async_clients or {} # Ensure it exists if _initialize_clients failed

    def _get_provider_fallback_order(self, primary_provider: str) -> List[str]:
        """
        Get provider fallback order for resilience.

        Provider priority based on reliability and cost:
        1. deepseek - Primary (most reliable, cost-effective)
        2. openai - Fallback (most reliable but expensive)
        3. moonshot - Fallback
        4. minimax - Fallback (Phase 68 integration)
        5. deepinfra - Last resort

        Args:
            primary_provider: The requested provider to try first

        Returns:
            List of provider IDs in fallback order
        """
        # All available providers that have clients initialized
        available_providers = list(self.async_clients.keys()) if self.async_clients else list(self.clients.keys())

        if not available_providers:
            return []

        # Fallback priority order (most reliable first)
        priority_order = ["deepseek", "openai", "moonshot", "minimax", "deepinfra"]

        # Build fallback list: primary first, then others in priority order
        fallback_order = []

        # Add primary provider first if it's available
        if primary_provider in available_providers:
            fallback_order.append(primary_provider)

        # Add remaining providers in priority order
        for provider in priority_order:
            if provider in available_providers and provider not in fallback_order:
                fallback_order.append(provider)

        # Add any remaining available providers not in priority list
        for provider in available_providers:
            if provider not in fallback_order:
                fallback_order.append(provider)

        return fallback_order

    def _refresh_excluded_cache(self):
        """Cache models with exclude_from_general_routing=True"""
        try:
            with get_db_session() as db:
                excluded = db.query(ModelCatalog.model_id).filter(
                    ModelCatalog.exclude_from_general_routing == True
                ).all()
                self.excluded_models = {m[0] for m in excluded}
                logger.debug(f"Refreshed excluded models cache: {len(self.excluded_models)} models excluded")
        except Exception as e:
            logger.warning(f"Failed to refresh excluded models cache: {e}")
            self.excluded_models = set()

    def _filter_by_capabilities(self, model_id: str, required_capability: Optional[str]) -> bool:
        """
        Check if model has the required capability.

        Args:
            model_id: Model identifier
            required_capability: Required capability (e.g., "computer_use", "vision", "tools")

        Returns:
            True if model has capability or no requirement, False otherwise
        """
        if not required_capability:
            return True  # No capability requirement

        try:
            with get_db_session() as db:
                model = db.query(ModelCatalog).filter_by(model_id=model_id).first()
                if not model:
                    return True  # Unknown models pass through
                capabilities = model.capabilities or ["chat"]
                return required_capability in capabilities
        except Exception as e:
            logger.warning(f"Failed to check capabilities for {model_id}: {e}")
            return True  # Pass through on error

    def _filter_by_health(self, provider_id: str) -> bool:
        """
        Check if provider is healthy enough for routing.

        Args:
            provider_id: Provider identifier

        Returns:
            True if provider is healthy (score >= 0.5) or unknown, False otherwise
        """
        if provider_id not in self.health_monitor.health_scores:
            return True  # Unknown providers pass through
        return self.health_monitor.get_health_score(provider_id) >= 0.5

    def _initialize_clients(self) -> None:
        """Initialize clients for all available providers"""
        if not OpenAI:
            logger.warning("OpenAI package not installed. LLM features may be limited.")
            return

        # Initialize OpenAI-compatible clients for each provider
        providers_config = {
            "openai": {"base_url": None},
            "deepseek": {"base_url": "https://api.deepseek.com/v1"},
            "moonshot": {"base_url": "https://api.moonshot.cn/v1"},
            "deepinfra": {"base_url": "https://api.deepinfra.com/v1/openai"},
            "minimax": {"base_url": "https://api.minimax.io/v1"},  # MiniMax M2.7 (OpenAI-compatible)
            "lux": {"base_url": None},  # Phase 226.2-01: LUX Computer Use (uses Anthropic API)
            "qwen": {"base_url": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"},
        }

        # Separate sync and async clients
        self.async_clients: Dict[str, Any] = {}

        # Phase 226.2-01: Special handling for LUX provider (uses Anthropic API key via lux_config)
        if "lux" in providers_config:
            # LUX uses Anthropic API key via lux_config or BYOK fallback
            api_key = lux_config.get_anthropic_key() or self.byok_manager.get_api_key("lux")
            if api_key:
                try:
                    self.clients["lux"] = OpenAI(api_key=api_key)
                    if AsyncOpenAI:
                        self.async_clients["lux"] = AsyncOpenAI(api_key=api_key)
                    logger.info("Initialized LUX provider with Anthropic client")
                except Exception as e:
                    logger.error(f"Failed to initialize LUX client: {e}")
            # Remove lux from providers_config so it doesn't get processed in the loop below
            del providers_config["lux"]

        for provider_id, config in providers_config.items():
            # Check if BYOK is configured for this provider and workspace
            if self.byok_manager.is_configured(self.workspace_id, provider_id):
                api_key = self.byok_manager.get_api_key(provider_id)
                try:
                    self.clients[provider_id] = OpenAI(
                        api_key=api_key,
                        base_url=config["base_url"] # base_url can be None for OpenAI
                    )
                    if AsyncOpenAI:
                        self.async_clients[provider_id] = AsyncOpenAI(
                            api_key=api_key,
                            base_url=config["base_url"]
                        )
                    logger.info(f"Initialized BYOK client for {provider_id}")
                except Exception as e:
                    logger.error(f"Failed to initialize {provider_id} client: {e}")
            else:
                # Fallback to env for development if BYOK not configured
                env_key = f"{provider_id.upper()}_API_KEY"
                api_key = os.getenv(env_key)
                if api_key:
                    try:
                        if config.get("base_url"):
                            self.clients[provider_id] = OpenAI(
                                api_key=api_key,
                                base_url=config["base_url"]
                            )
                            if AsyncOpenAI:
                                self.async_clients[provider_id] = AsyncOpenAI(
                                    api_key=api_key,
                                    base_url=config["base_url"]
                                )
                        else:
                            self.clients[provider_id] = OpenAI(api_key=api_key)
                            if AsyncOpenAI:
                                self.async_clients[provider_id] = AsyncOpenAI(api_key=api_key)
                        logger.info(f"Initialized BYOK client for {provider_id}")
                    except Exception as e:
                        logger.error(f"Failed to initialize {provider_id} client: {e}")

    def get_context_window(self, model_name: str) -> int:
        """
        Get the context window size for a model from dynamic pricing data.
        Returns a safe default if not found.
        """
        try:
            fetcher = get_pricing_fetcher()
            pricing = fetcher.get_model_price(model_name)
            if pricing:
                # Prefer max_input_tokens, fall back to max_tokens
                return pricing.get("max_input_tokens") or pricing.get("max_tokens") or 4096
        except Exception as e:
            logger.debug(f"Could not get context window for {model_name}: {e}")
        
        # Safe defaults by provider/model
        CONTEXT_DEFAULTS = {
            "gpt-4o": 128000,
            "gpt-4o-mini": 128000,
            "gpt-4": 8192,
            "claude-3": 200000,
            "deepseek-chat": 32768,
            "deepseek-reasoner": 32768,
            "gemini": 1000000,  # Gemini has huge context
        }
        for key, size in CONTEXT_DEFAULTS.items():
            if key in model_name.lower():
                return size
        return 4096  # Conservative default

    def truncate_to_context(self, text: str, model_name: str, reserve_tokens: int = 1000) -> str:
        """
        Truncate text to fit within the model's context window.
        Reserves tokens for the response.
        """
        context_window = self.get_context_window(model_name)
        max_input_tokens = context_window - reserve_tokens
        
        # Approximate: 1 token ≈ 4 characters
        max_chars = max_input_tokens * 4
        
        if len(text) <= max_chars:
            return text
        
        # Truncate and add indicator
        truncated = text[:max_chars - 100]
        truncated += "\n\n[... Content truncated to fit context window ...]"
        logger.warning(f"Truncated prompt from {len(text)} to {len(truncated)} chars for {model_name}")
        return truncated

    def analyze_query_complexity(self, prompt: str, task_type: Optional[str] = None) -> QueryComplexity:
        """
        Analyze query complexity to determine optimal provider routing.
        Uses a robust regex-based heuristic with expanded vocabulary.
        """
        # 1. Length-based scoring (estimated tokens)
        estimated_tokens = len(prompt) / 4
        complexity_score = 0
        
        if estimated_tokens >= 2000:
            complexity_score += 3
        elif estimated_tokens >= 500:
            complexity_score += 2
        elif estimated_tokens >= 100:
            complexity_score += 1

        # 2. Regex-based vocabulary analysis
        # Using word boundaries \b to avoid matches inside other words
        patterns = {
            "simple": (r"\b(hello|hi|thanks|greetings|summarize|translate|list|what is|who is|define|how do i|simplify|brief|basic|short|quick|simple)\b", -2),
            "moderate": (r"\b(analyze|compare|evaluate|synthesize|explain|describe|detailed|background|concept|history|nuance|opinion|critique|pros and cons|advantages|disadvantages)\b", 1),
            "technical": (r"\b(calculate|equation|formula|solve|integral|derivative|calculus|geometry|algebra|math|maths|theorem|statistics|probability|regression|vector|matrix|tensor|log|exp|pow|sqrt|abs|sin|cos|tan|pi|infinity|prime|physics|chemistry|biology|science)\b", 3),
            "code": (r"\b(code|coding|function|class|method|script|scripting|debug|debugging|optimize|optimization|refactor|refactoring|snippet|implementation|interface|api|endpoint|webhook|database|sql|postgresql|mongodb|redis|schema|migration|json|xml|yaml|config|docker|kubernetes|aws|lambda|gcp|azure|def|var|let|const|import|return|print|async|await|try|except|catch|throw|public|private|static|final|struct|typedef|typedefs)\b", 3),
            "advanced": (r"\b(architecture|architecting|security audit|vulnerability|cryptography|encryption|decryption|authentication|authorization|auth|oauth|jwt|performance|bottleneck|concurrency|multithread|parallel|distributed|scale|scaling|load balance|cluster|proprietary|reverse engineer|obfuscate|obfuscation|enterprise|global|large-scale)\b", 5)
        }

        # Check for code blocks (significant weight)
        if "```" in prompt:
            complexity_score += 3

        for name, (pattern, weight) in patterns.items():
            if re.search(pattern, prompt, re.IGNORECASE):
                complexity_score += weight

        # 3. Task type override
        if task_type:
            if task_type in ["code", "analysis", "reasoning"]:
                complexity_score += 2
            elif task_type in ["chat", "general"]:
                complexity_score -= 1

        # 4. Map score to complexity level
        # Refined ranges: 2+ is COMPLEX, 5+ is ADVANCED
        if complexity_score <= 0:
            return QueryComplexity.SIMPLE
        elif complexity_score == 1:
            return QueryComplexity.MODERATE
        elif complexity_score <= 4:
            return QueryComplexity.COMPLEX
        else:
            return QueryComplexity.ADVANCED

    async def get_optimal_provider(
        self, 
        complexity: QueryComplexity, 
        task_type: Optional[str] = None, 
        prefer_cost: bool = True,
        tenant_plan: str = "free",
        is_managed_service: bool = True,
        requires_tools: bool = False, # Phase 6.6
        requires_structured: bool = False, # Phase 6.6
        turn_index: int = 0
    ) -> tuple[str, str]:
        """Get the single most optimal provider and model."""
        options = await self.get_ranked_providers(
            complexity, task_type, prefer_cost, tenant_plan, 
            is_managed_service, requires_tools, requires_structured,
            turn_index=turn_index
        )
        if options:
            return options[0]
        
        # Absolute fallback
        if self.clients:
            provider_id = list(self.clients.keys())[0]
            return provider_id, "gpt-4o-mini"
            
        raise ValueError("No LLM providers available. Please configure BYOK keys.")

    async def get_ranked_providers(
        self,
        complexity: QueryComplexity,
        task_type: Optional[str] = None,
        prefer_cost: bool = True,
        tenant_plan: str = "free",
        is_managed_service: bool = True,
        requires_tools: bool = False, # Phase 6.6
        requires_structured: bool = False, # Phase 6.6
        estimated_tokens: int = 1000, # Cache-aware routing
        workspace_id: str = "default", # Cache-aware routing
        cognitive_tier: Optional[CognitiveTier] = None,  # Phase 68: Cognitive tier system
        required_capability: Optional[str] = None,  # Phase 226.4-04: Capability-based routing
        turn_index: int = 0 # NEW: Deterministic BPC
    ) -> List[tuple[str, str]]:
        """
        Get a ranked list of providers and models using the BPC (Benchmark-Price-Capability) algorithm.
        This objectively ranks models based on their value proposition.

        Cache-Aware Extension (Deterministic):
        Uses turn_index (0 = first turn, 1+ = repeat turns) to determine whether
        to use full input price or cached input price.

        Phase 68 Extension:
        When cognitive_tier is provided, uses CognitiveTier-based quality filtering instead of
        QueryComplexity. This enables more granular 5-tier quality control.

        Phase 226.4-04 Extension:
        When required_capability is provided, filters models by capability (e.g., "computer_use", "vision", "tools")
        and uses capability-specific quality scores. Also filters out excluded models and unhealthy providers.

        Args:
            complexity: Query complexity level
            task_type: Optional task type hint
            prefer_cost: Whether to prefer cost over quality
            tenant_plan: Tenant plan for model restrictions
            cognitive_tier: Optional CognitiveTier for 5-tier quality filtering (Phase 68)
            is_managed_service: Whether this is managed service or BYOK
            requires_tools: Whether model must support tool calling
            requires_structured: Whether model must support structured output
            estimated_tokens: Estimated input token count (for cache hit prediction)
            workspace_id: Workspace ID for cache history lookup
            required_capability: Optional capability requirement (e.g., "computer_use", "vision", "tools")
            turn_index: Interaction turn (0 = creation, 1+ = reuse)

        Returns:
            List of (provider, model) tuples ranked by value score
        """
        ranked_options = []
        
        # 1. Dynamic BPC Selection (Data-Driven)
        try:
            fetcher = get_pricing_fetcher()
            
            # Context window requirements
            MIN_CONTEXT_BY_COMPLEXITY = {
                QueryComplexity.SIMPLE: 4000,
                QueryComplexity.MODERATE: 8000,
                QueryComplexity.COMPLEX: 16000,
                QueryComplexity.ADVANCED: 32000
            }
            min_context = MIN_CONTEXT_BY_COMPLEXITY.get(complexity, 8000)

            # Filter criteria for benchmarks based on complexity
            # Phase 68: Use CognitiveTier thresholds if provided
            if cognitive_tier is not None:
                min_quality = MIN_QUALITY_BY_TIER.get(cognitive_tier, 0)
                logger.debug(f"Using CognitiveTier {cognitive_tier.value} quality threshold: {min_quality}")
            else:
                MIN_QUALITY_BY_COMPLEXITY = {
                    QueryComplexity.SIMPLE: 0,
                    QueryComplexity.MODERATE: 80,
                    QueryComplexity.COMPLEX: 88,
                    QueryComplexity.ADVANCED: 94
                }
                min_quality = MIN_QUALITY_BY_COMPLEXITY.get(complexity, 0)
            
            available_providers = list(self.clients.keys())
            candidates = []
            
            # Use the entire pricing cache to discover models beyond hardcoded lists
            for model_id, pricing in fetcher.pricing_cache.items():
                litellm_provider = pricing.get("litellm_provider", "").lower()
                
                # Check if we have a client for this provider
                active_provider = next((p for p in available_providers if p in model_id.lower() or p == litellm_provider), None)
                if not active_provider:
                    continue
                
                # Check context window
                context_window = pricing.get("max_input_tokens") or pricing.get("max_tokens") or 0
                if context_window < min_context:
                    continue

                # Phase 226.4-04: Check capability filter
                if not self._filter_by_capabilities(model_id, required_capability):
                    continue

                # Phase 226.4-04: Check if model is excluded from general routing
                if not required_capability and model_id in self.excluded_models:
                    continue

                # Phase 226.4-04: Check provider health
                if not self._filter_by_health(active_provider):
                    continue

                # Check quality score (use capability-specific score if required)
                if required_capability:
                    quality_score = get_capability_score(model_id, required_capability)
                else:
                    quality_score = get_quality_score(model_id)

                if quality_score < min_quality:
                    continue

                # Calculate BPC Value Score with Cache-Aware Cost
                # Value = (Quality^2) / Cost. We use 1e6 to make costs readable.

                # Calculate DETERMINISTIC cache-aware effective cost (Turn 0 vs Turn N)
                effective_cost = await self.cache_router.calculate_effective_cost(
                    model_id, active_provider, estimated_tokens, turn_index=turn_index
                )

                # Avoid division by zero and handle free models
                normalized_cost = max(effective_cost, 1e-9)

                # BPC Score: Higher is better value
                # Squaring quality penalizes low-end models regardless of price for complex tasks
                value_score = (quality_score ** 2) / (normalized_cost * 1e6)
                
                candidates.append({
                    "provider": active_provider,
                    "model": model_id,
                    "value_score": value_score,
                    "quality": quality_score,
                    "cost": effective_cost
                })
            
            # Sort by Value Score (Descending)
            candidates.sort(key=lambda x: x["value_score"], reverse=True)
            
            # Filter by plan restrictions
            allowed_models = MODEL_TIER_RESTRICTIONS.get(tenant_plan.lower(), MODEL_TIER_RESTRICTIONS["free"]) if is_managed_service else "*"
            
            def is_model_approved(model_id: str, allowed_list: any) -> bool:
                if allowed_list == "*" or "*" in allowed_list:
                    return True
                
                # Flexible matching: check if any allowed model name is part of the actual model_id
                model_id_lower = model_id.lower()
                
                # Check Tool/Structured constraints (Phase 6.6)
                if (requires_tools or requires_structured) and any(m in model_id_lower for m in MODELS_WITHOUT_TOOLS):
                    return False

                return any(m.lower() in model_id_lower for m in allowed_list)

            for c in candidates:
                if is_model_approved(c["model"], allowed_models):
                    ranked_options.append((c["provider"], c["model"]))
            
            if ranked_options:
                logger.info(f"BPC Ranking Successful for {complexity.value}: Top model {ranked_options[0][1]} (Value: {candidates[0]['value_score']:.2f})")
                return ranked_options
                
        except Exception as e:
            logger.debug(f"BPC ranking failed, falling back to static mapping: {e}")
        
        # 2. Static Fallback (if BPC logic fails or cache empty)
        if complexity == QueryComplexity.SIMPLE:
            provider_priority = ["deepseek", "minimax", "qwen", "moonshot", "gemini", "openai", "anthropic"]
        elif complexity == QueryComplexity.MODERATE:
            provider_priority = ["deepseek", "minimax", "qwen", "gemini", "moonshot", "openai", "anthropic"]
        elif complexity == QueryComplexity.COMPLEX:
            provider_priority = ["gemini", "deepseek", "anthropic", "qwen", "minimax", "openai", "moonshot"]
        else: # ADVANCED
            provider_priority = ["openai", "deepseek", "anthropic", "qwen", "gemini", "moonshot", "minimax"]
        
        for provider_id in provider_priority:
            if provider_id in self.clients:
                models = COST_EFFICIENT_MODELS.get(provider_id, {})
                model = models.get(complexity, "gpt-4o-mini")
                
                if not is_managed_service:
                    # Filter for tool support even in BYOK (Phase 6.6)
                    if (requires_tools or requires_structured) and model in MODELS_WITHOUT_TOOLS:
                        # Fallback to r2 if speciale is disallowed
                        if provider_id == "deepseek" and model == "deepseek-v3.2-speciale":
                            model = "deepseek-r2"
                        else:
                            continue

                    ranked_options.append((provider_id, model))
                    continue

                allowed_models = MODEL_TIER_RESTRICTIONS.get(tenant_plan.lower(), MODEL_TIER_RESTRICTIONS["free"])
                
                # Check Tool/Structured Support (Phase 6.6)
                if (requires_tools or requires_structured) and model in MODELS_WITHOUT_TOOLS:
                    # Try to downgrade to a model that supports tools within the same provider
                    if provider_id == "deepseek" and model == "deepseek-v3.2-speciale":
                        model = "deepseek-r2" # r2 supports tools/structured
                    else:
                        continue # Skip this provider if no fallback found

                if "*" in allowed_models or model in allowed_models:
                    ranked_options.append((provider_id, model))
                    
        # Phase 68-Q: Boost Qwen to top if available and requested
        if "qwen" in self.clients:
            qwen_option = next(((p, m) for p, m in ranked_options if p == "qwen"), None)
            if qwen_option:
                ranked_options.remove(qwen_option)
                ranked_options.insert(0, qwen_option)

        return ranked_options

    async def generate_response(
        self, 
        prompt: str, 
        system_instruction: str = "You are a helpful assistant.",
        model_type: str = "auto",  # "auto", "fast", "quality", or specific model
        temperature: float = 0.7,
        task_type: Optional[str] = None,
        prefer_cost: bool = True,
        agent_id: Optional[str] = None, # Phase 65
        chain_id: Optional[str] = None, # NEW Phase 11
        image_payload: Optional[str] = None, # Phase 14: Base64 or URL
        turn_index: int = 0 # NEW: Deterministic BPC
    ) -> str:
        """
        Generate a response using cost-optimized provider routing.
        Supports multimodal inputs (text + image) via `image_payload`.
        """
        # Phase 72: Trial Restriction Check
        if self._is_trial_restricted():
            logger.warning(f"AI Blocked: Trial expired for workspace {self.workspace_id}")
            return "Trial Expired: Your free trial has ended. Please upgrade your plan in settings to continue using AI agents."
        if not self.clients:
            if task_type == "agentic":
                # FOR DEMO: Return a mock JSON that continues the agentic loop
                if "Check my inbox" in prompt or "analyze" in prompt.lower() or "market" in prompt.lower():
                    return json.dumps({
                        "thought": "The user wants a full end-to-end machinery quote and client analysis. I will start by performing the market analysis.",
                        "plan_update": ["Perform market analysis for brennan.ca", "Read inbound emails", "Calculate quote and save to Excel", "Update CRM", "Send final email with meeting invite"],
                        "action": "perform_market_analysis",
                        "action_input": {"client_url": "brennan.ca", "product_name": "5-Axis CNC Mill"},
                        "log": "> Starting Market Analysis for Brennan.ca...",
                        "deliverable": None
                    })
                return json.dumps({
                    "thought": "LLM not initialized, but running in agentic demo mode.",
                    "action": "DONE",
                    "log": "AI Employee Demo Mode active (No API Keys found)."
                })
            return "LLM Client not initialized (No API Keys configured)."
        
        # --- Budget Enforcement (Phase 56) ---
        if llm_usage_tracker.is_budget_exceeded(self.workspace_id):
            logger.warning(f"AI Generation Blocked: Budget exceeded for workspace {self.workspace_id}")
            return "🚨 BUDGET EXCEEDED: Your AI usage has reached 100% of your limit. Please increase your budget in Settings to continue."

        try:
            # --- Tier & Pricing Mode Enforcement (Phase 59 Refinement) ---
            
            with get_db_session() as db:
                try:
                    tenant_plan = "free"
                    is_managed = True

                    workspace = db.query(Workspace).filter(Workspace.id == self.workspace_id).first()
                    if workspace and workspace.tenant_id:
                        tenant = db.query(Tenant).filter(Tenant.id == (self.tenant_id if self.tenant_id != "default" else workspace.tenant_id)).first()
                        if tenant:
                            # 1. Determine Plan level
                            plan_type = tenant.plan_type
                            tenant_plan = plan_type.value if hasattr(plan_type, 'value') else str(plan_type).lower()

                            # 2. Determine if Managed or BYOK (Phase 50 Hybrid Logic)
                            complexity = self.analyze_query_complexity(prompt, task_type)

                            # Agents always require tools (Phase 6.6)
                            requires_tools = agent_id is not None or task_type == "agentic"

                            # Temporary provider check for key resolution
                            temp_provider_id, _ = await self.get_optimal_provider(
                                complexity, task_type, prefer_cost, tenant_plan,
                                is_managed_service=True, requires_tools=requires_tools,
                                turn_index=turn_index
                            )

                            tenant_key = self.byok_manager.get_tenant_api_key(self.tenant_id, temp_provider_id)
                            if tenant_key:
                                is_managed = False  # Custom Key = BYOK
                            elif tenant_plan.lower() in [p.lower() for p in BYOK_ENABLED_PLANS]:
                                is_managed = False  # Enterprise Plan = BYOK

                            # 3. Block Managed AI for Free Tier (Phase 59 User Req) - BYPASSED for AI Employee Demo
                            # We bypass this for 'agentic' task types to allow the demo to function
                            if is_managed and tenant_plan.lower() == "free" and task_type != "agentic":
                                # Check if we have ANY local api keys that can be used instead
                                if not self.clients:
                                    return "🚨 PLAN RESTRICTION: Managed AI is not available on the Free plan. Please add your own API key in Settings or upgrade to a Pro plan to continue."
                except Exception as e:
                    logger.warning(f"Failed to fetch tenant plan: {e}")

            # --- Phase 14-BYOK: Force BYOK behavior if local keys exist for agentic tasks ---
            if task_type == "agentic" and self.clients:
                is_managed = False
                tenant_plan = "enterprise" # Effectively unrestricted
                logger.info("Using local/BYOK mode for agentic task demo")

            # Analyze complexity
            complexity = self.analyze_query_complexity(prompt, task_type)
            
            # Identify tool/structured requirements (Phase 6.6)
            requires_tools = agent_id is not None or task_type == "agentic"
            
            # --- Phase 14: Vision Routing ---
            # If image payload exists, we MUST route to a model that supports vision (GPT-4o, Gemini 1.5 Pro)
            # We override the normal routing logic to prioritize Vision-Capable models
            requires_vision = image_payload is not None
            
            # Get ranked list of providers
            options = await self.get_ranked_providers(
                complexity, task_type, prefer_cost, tenant_plan, is_managed,
                requires_tools=requires_tools, requires_structured=False,
                turn_index=turn_index
            )

            # --- Phase 14.5: Coordinated Vision Logic ---
            if requires_vision:
                # Check if the primary ranked model supports vision natively
                primary_provider, primary_model = options[0] if options else (None, None)
                
                if primary_model and any(m in primary_model.lower() for m in REASONING_MODELS_WITHOUT_VISION):
                    logger.info(f"Coordinating vision for non-vision reasoning model: {primary_model}")
                    vision_desc = await self._get_coordinated_vision_description(
                        image_payload=image_payload,
                        tenant_plan=tenant_plan,
                        is_managed=is_managed
                    )
                    if vision_desc:
                        mapping_instr = (
                            "\n[COORDINATE MAPPING]:\n"
                            "The coordinates below are on a normalized 1000x1000 grid. "
                            "The browser viewport is 1280 pixels wide. "
                            "To click an element at [x, y], use browser_click_coords(x*1.28, y*H) where H is approximately 0.72*1.28.\n"
                        )
                        prompt = f"[VISUAL CONTEXT ANALYSIS]:\n{vision_desc}\n{mapping_instr}\n\n[USER REQUEST]:\n{prompt}"
                        # Disable image_payload for the reasoning call
                        image_payload = None 
                        requires_vision = False

            # Filter for Vision logic if needed
            if requires_vision:
                # 1. Specialized Task Preference (e.g., DeepSeek-OCR for PDF)
                if task_type == "pdf_ocr":
                    # Prefer DeepInfra DeepSeek-OCR or Direct DeepSeek
                    preferred_ocr = [(p, m) for p, m in options if "deepinfra" in p.lower() or "deepseek" in p.lower() or ("deepseek" in m.lower() and "ocr" in m.lower())]
                    if preferred_ocr:
                        options = preferred_ocr
                        logger.info(f"Prioritizing {preferred_ocr[0][0]} for PDF OCR task")

                # 2. Naive filter: Only keep known vision models if not already specialized
                # Phase 226.2-01: Added "lux" for computer use tasks
                vision_models = ["gpt-4o", "gemini-3-flash", "gemini-2.0-flash", "gemini-1.5-flash", "gemini-1.5-pro", "claude-3-5-sonnet", "claude-3-opus", "gpt-4-turbo", "deepseek", "deepinfra", "lux"]
                vision_options = []
                for prov, mod in options:
                    if any(v in mod.lower() for v in vision_models):
                        vision_options.append((prov, mod))
                
                if vision_options:
                    options = vision_options
                elif not any("deepseek" in p.lower() for p, m in options):
                    # Fallback default if no ranked vision option matches
                    logger.warning("No standard vision models found in ranked options. Defaulting to GPT-4o.")
                    options = [("openai", "gpt-4o")] # Panic fallback
            
            if not options:
                return "No eligible LLM providers found for your current plan."

            last_error = None
            for provider_id, model in options:
                try:
                    import time
                    request_start = time.time()
                    client = self.clients[provider_id]
                    
                    # Construct Messages (Phase 14: Multimodal)
                    messages = []
                    messages.append({"role": "system", "content": system_instruction})
                    
                    if image_payload:
                        # OpenAI / Compatible Vision Format
                        user_content = [
                            {"type": "text", "text": prompt},
                            {
                                "type": "image_url",
                                "image_url": {
                                    "url": image_payload if image_payload.startswith("http") else f"data:image/jpeg;base64,{image_payload}"
                                }
                            }
                        ]
                        messages.append({"role": "user", "content": user_content})
                        logger.info(f"Adding visual payload to request for {model}")
                    else:
                        messages.append({"role": "user", "content": prompt})

                    # Make the request
                    response = client.chat.completions.create(
                        model=model,
                        messages=messages,
                        temperature=temperature
                    )
                    
                    result = response.choices[0].message.content
                    
                    # --- Dynamic Cost Attribution (Phase 47) ---
                    usage = getattr(response, 'usage', None)
                    if usage:
                        input_tokens = getattr(usage, 'prompt_tokens', 0)
                        output_tokens = getattr(usage, 'completion_tokens', 0)
                        
                        # Calculate real cost from dynamic pricing
                        try:
                            fetcher = get_pricing_fetcher()
                            cost = fetcher.estimate_cost(model, input_tokens, output_tokens)
                            
                            # Calculate Reference Cost (gpt-4o) for savings tracking (Phase 58)
                            reference_cost = fetcher.estimate_cost("gpt-4o", input_tokens, output_tokens)
                            savings_usd = max(0, reference_cost - cost) if reference_cost and cost is not None else 0.0
                            
                            # Fallback to static pricing if dynamic not available
                            if cost is None:
                                cost = get_llm_cost(model, input_tokens, output_tokens)
                                # Static reference cost fallback
                                ref_cost_static = get_llm_cost("gpt-4o", input_tokens, output_tokens)
                                savings_usd = max(0, ref_cost_static - cost)
                            
                            if cost and cost > 0:
                                # Record to LLM Usage Tracker
                                llm_usage_tracker.record(
                                    workspace_id=self.workspace_id,
                                    provider=provider_id,
                                    model=model,
                                    input_tokens=input_tokens,
                                    output_tokens=output_tokens,
                                    cost_usd=cost,
                                    savings_usd=savings_usd,
                                    agent_id=agent_id,
                                    chain_id=chain_id, # Phase 11
                                    complexity=complexity.value, # Phase 6.6
                                    is_managed_service=is_managed
                                )
                                logger.info(f"LLM Cost Attributed ({'Managed' if is_managed else 'BYOK'}): {model} - ${cost:.6f} (Saved: ${savings_usd:.6f})")
                        except Exception as cost_err:
                            logger.warning(f"Could not attribute LLM cost: {cost_err}")

                        # --- Cache Outcome Recording (Phase 68) ---
                        # Record whether the request hit the prompt cache for future predictions
                        try:
                            prompt_hash = hashlib.sha256(f"{self.workspace_id}:{provider_id}:{model}".encode()).hexdigest()

                            # Check if response usage includes caching info
                            was_cached = False
                            if hasattr(usage, 'prompt_cache_hit_tokens'):
                                # Anthropic provides explicit cache hit token count
                                was_cached = getattr(usage, 'prompt_cache_hit_tokens', 0) > 0
                            elif hasattr(response, 'cache_controls'):
                                # OpenAI provides cache controls in response
                                was_cached = True  # If cache controls were present, it was cached

                            # Record outcome for future predictions
                            self.cache_router.record_cache_outcome(prompt_hash, self.workspace_id, was_cached)
                            logger.debug(f"Cache outcome recorded: {prompt_hash[:16]} -> {was_cached}")
                        except Exception as cache_err:
                            logger.debug(f"Could not record cache outcome: {cache_err}")

                    # Log for analytics
                    logger.info(f"BYOK Logic: complexity={complexity.value}, provider={provider_id}, model={model}")

                    # Phase 226.4-04: Record successful API call for health monitoring
                    latency_ms = (time.time() - request_start) * 1000
                    self.health_monitor.record_call(provider_id, success=True, latency_ms=latency_ms)

                    return result

                except Exception as attempt_err:
                    logger.warning(f"Attempt failed for {provider_id}/{model}: {attempt_err}")
                    last_error = attempt_err

                    # Phase 226.4-04: Record failed API call for health monitoring
                    try:
                        latency_ms = (time.time() - request_start) * 1000
                        self.health_monitor.record_call(provider_id, success=False, latency_ms=latency_ms)
                    except:
                        pass  # Don't let health monitoring errors affect primary flow
                    continue # Try next provider
            
            return f"All providers failed. Last error: {str(last_error)}"
            
        except Exception as e:
            logger.error(f"LLM Generation failed: {e}")
            return f"Error generating response: {str(e)}"

    async def generate_with_cognitive_tier(
        self,
        prompt: str,
        system_instruction: str = "You are a helpful assistant.",
        task_type: Optional[str] = None,
        user_tier_override: Optional[str] = None,
        agent_id: Optional[str] = None,
        image_payload: Optional[str] = None
    ) -> Dict[str, Any]:
        """
        Generate response using full cognitive tier pipeline.

        Phase 68-06: Integrates CognitiveTierService for end-to-end intelligent routing.

        Pipeline:
        1. Select cognitive tier (classification + workspace preferences)
        2. Check budget constraints (monthly + per-request)
        3. Get optimal model (cache-aware cost scoring)
        4. Generate with automatic escalation on quality issues

        Args:
            prompt: The user query
            system_instruction: System prompt for the LLM
            task_type: Optional task type hint (code, chat, analysis, etc.)
            user_tier_override: Optional user-specified tier (bypasses classification)
            agent_id: Optional agent ID for cost tracking
            image_payload: Optional base64/URL image for multimodal input

        Returns:
            Dictionary with keys:
            - response: Generated text response
            - tier: Cognitive tier used
            - provider: Provider ID used
            - model: Model name used
            - cost_cents: Estimated cost in cents
            - escalated: Whether escalation occurred

        Example:
            >>> handler = BYOKHandler()
            >>> result = await handler.generate_with_cognitive_tier(
            ...     "explain quantum computing",
            ...     task_type="analysis"
            ... )
            >>> print(result["response"])
            >>> print(f"Tier: {result['tier']}, Model: {result['model']}")
        """
        request_id = str(uuid.uuid4())

        # Phase 68-06: Step 1 - Select tier using CognitiveTierService
        tier = self.tier_service.select_tier(prompt, task_type, user_tier_override)

        # Phase 68-06: Step 2 - Check budget constraints
        estimated_cost = self.tier_service.calculate_request_cost(prompt, tier, None)
        if not self.tier_service.check_budget_constraint(estimated_cost.get('cost_cents', 0)):
            logger.warning(f"Budget exceeded for request {request_id}")
            return {
                "error": "Budget exceeded",
                "tier": tier.value,
                "estimated_cost_cents": estimated_cost.get('cost_cents', 0)
            }

        # Phase 68-06: Step 3 - Get optimal model (cache-aware)
        estimated_tokens = len(prompt) // 4
        requires_tools = agent_id is not None or task_type == "agentic"

        provider_id, model = self.tier_service.get_optimal_model(
            tier, estimated_tokens, requires_tools
        )

        if not provider_id or not model:
            logger.warning(f"No models available for tier: {tier.value}")
            return {
                "error": "No models available for this tier",
                "tier": tier.value
            }

        # Phase 68-06: Step 4 - Generate with escalation loop
        current_tier = tier
        max_escalations = 2
        escalated = False

        for attempt in range(max_escalations + 1):
            try:
                # Generate response
                response = await self.generate_response(
                    prompt=prompt,
                    system_instruction=system_instruction,
                    model_type=model,  # Use specific model from tier service
                    task_type=task_type,
                    agent_id=agent_id,
                    image_payload=image_payload
                )

                # Phase 68-06: Step 5 - Check for escalation
                should_escalate, reason, target_tier = self.tier_service.handle_escalation(
                    current_tier, None, None, False, request_id
                )

                if not should_escalate:
                    # Success - return response with metadata
                    return {
                        "response": response,
                        "tier": current_tier.value,
                        "provider": provider_id,
                        "model": model,
                        "cost_cents": estimated_cost.get('cost_cents', 0),
                        "escalated": escalated,
                        "request_id": request_id
                    }

                # Escalate and retry
                logger.info(
                    f"Escalating request {request_id} from {current_tier.value} "
                    f"to {target_tier.value} (reason: {reason.value})"
                )
                current_tier = target_tier
                escalated = True

                # Get new model for escalated tier
                provider_id, model = self.tier_service.get_optimal_model(
                    current_tier, estimated_tokens, requires_tools
                )

                if not provider_id or not model:
                    logger.warning(f"No models available for escalated tier: {current_tier.value}")
                    # Return response from previous attempt
                    return {
                        "response": response,
                        "tier": tier.value,
                        "provider": provider_id,
                        "model": model,
                        "cost_cents": estimated_cost.get('cost_cents', 0),
                        "escalated": escalated,
                        "request_id": request_id
                    }

            except Exception as e:
                # Check for rate limit escalation
                is_rate_limited = "rate limit" in str(e).lower()

                should_escalate, reason, target_tier = self.tier_service.handle_escalation(
                    current_tier, None, str(e), is_rate_limited, request_id
                )

                if should_escalate and target_tier and attempt < max_escalations:
                    logger.warning(
                        f"Escalating request {request_id} due to error: {reason.value}"
                    )
                    current_tier = target_tier
                    escalated = True

                    # Get new model for escalated tier
                    provider_id, model = self.tier_service.get_optimal_model(
                        current_tier, estimated_tokens, requires_tools
                    )

                    if not provider_id or not model:
                        # No fallback available - return error
                        return {
                            "error": str(e),
                            "tier": current_tier.value,
                            "escalated": escalated
                        }

                    continue  # Retry with escalated tier

                # Max escalations reached or non-escalatable error
                logger.error(f"Generation failed after {attempt + 1} attempts: {e}")
                return {
                    "error": str(e),
                    "tier": current_tier.value,
                    "escalated": escalated
                }

        # Should not reach here, but return last response if loop completes
        return {
            "response": "Max escalation limit reached",
            "tier": current_tier.value,
            "escalated": escalated
        }

    async def generate_structured_response(
        self,
        prompt: str,
        system_instruction: str,
        response_model: Any,
        temperature: float = 0.2,
        task_type: Optional[str] = None,
        agent_id: Optional[str] = None,
        chain_id: Optional[str] = None, # NEW Phase 11
        image_payload: Optional[str] = None # Phase 14: Vision Support
    ) -> Any:
        """
        Generate a structured response using instructor with tenant-aware routing.
        Works with both BYOK and Managed AI.
        Supports multimodal inputs via `image_payload`.
        
        Args:
            prompt: The user prompt
            system_instruction: System instruction for the LLM
            response_model: Pydantic model class for structured output
            temperature: Sampling temperature
            task_type: Optional task type hint
            agent_id: Optional agent ID for cost tracking
            image_payload: Optional Base64 image string or URL
            
        Returns:
            Instance of response_model or None if parsing fails
        """
        # Check trial/budget restrictions
        if self._is_trial_restricted():
            logger.warning(f"AI Blocked: Trial expired for workspace {self.workspace_id}")
            return None
            
        if not self.clients:
            logger.warning("No LLM clients available")
            return None
        
        try:
            # Check if instructor is available
            if not INSTRUCTOR_AVAILABLE:
                logger.warning("Instructor not available, falling back to raw response")
                return None
            
            # Get tenant plan and determine BYOK vs managed
            with get_db_session() as db:
                try:
                    tenant_plan = "free"
                    is_managed = True

                    workspace = db.query(Workspace).filter(Workspace.id == self.workspace_id).first()
                    if workspace and workspace.tenant_id:
                        tenant = db.query(Tenant).filter(Tenant.id == workspace.tenant_id).first()
                        if tenant:
                            plan_type = tenant.plan_type
                            tenant_plan = plan_type.value if hasattr(plan_type, 'value') else str(plan_type).lower()

                            # Check for custom BYOK keys
                            complexity = self.analyze_query_complexity(prompt, task_type)
                            temp_provider_id, _ = self.get_optimal_provider(complexity, task_type, True, tenant_plan, is_managed_service=True)

                            tenant_key = self.byok_manager.get_tenant_api_key(tenant.id, temp_provider_id)
                            if tenant_key:
                                is_managed = False
                            elif tenant_plan.lower() in [p.lower() for p in BYOK_ENABLED_PLANS]:
                                is_managed = False
                except Exception as e:
                    logger.warning(f"Failed to get tenant plan: {e}")
            
            # Block free tier managed AI
            if is_managed and tenant_plan.lower() == "free":
                logger.warning(f"Managed AI blocked for free tier workspace {self.workspace_id}")
                return None
            
            # Get optimal provider and model
            complexity = self.analyze_query_complexity(prompt, task_type)
            
            # Structured generation requires structured support (Phase 6.6)
            requires_tools = agent_id is not None or task_type == "agentic"
            
            # --- Phase 14: Vision Routing ---
            requires_vision = image_payload is not None
            # Get ranked options
            options = self.get_ranked_providers(
                complexity, task_type, True, tenant_plan, is_managed,
                requires_tools=True, requires_structured=True
            )

            # --- Phase 14.5: Coordinated Vision Logic ---
            if image_payload:
                primary_provider, primary_model = options[0] if options else (None, None)
                if primary_model and any(m in primary_model.lower() for m in REASONING_MODELS_WITHOUT_VISION):
                    logger.info(f"Coordinating vision (structured) for non-vision reasoning model: {primary_model}")
                    vision_desc = await self._get_coordinated_vision_description(
                        image_payload=image_payload,
                        tenant_plan=tenant_plan,
                        is_managed=is_managed
                    )
                    if vision_desc:
                        mapping_instr = (
                            "\n[COORDINATE MAPPING]:\n"
                            "The coordinates below are on a normalized 1000x1000 grid. "
                            "The browser viewport is 1280 pixels wide. "
                            "To click an element at [x, y], use browser_click_coords(x*1.28, y*H) where H is approximately 0.72*1.28.\n"
                        )
                        prompt = f"[VISUAL CONTEXT ANALYSIS]:\n{vision_desc}\n{mapping_instr}\n\n[USER REQUEST]:\n{prompt}"
                        image_payload = None 
            
            # Filter for Vision logic if needed
            if requires_vision:
                vision_models = ["gpt-4o", "gemini-3-flash", "gemini-2.0-flash", "gemini-1.5-flash", "gemini-1.5-pro", "claude-3-5-sonnet", "claude-3-opus", "gpt-4-turbo"]
                vision_options = []
                for prov, mod in options:
                    if any(v in mod.lower() for v in vision_models):
                        vision_options.append((prov, mod))
                
                if vision_options:
                    options = vision_options
                else:
                    logger.warning("No standard vision models found for structured output. Defaulting to GPT-4o.")
                    options = [("openai", "gpt-4o")] # Panic fallback

            if not options:
                return None

            last_error = None
            for provider_id, model in options:
                try:
                    # Get the client and wrap with instructor
                    client = self.clients[provider_id]
                    instructor_client = instructor.from_openai(client)
                    
                    # Truncate prompts to fit context window
                    context_window = self.get_context_window(model)
                    if len(prompt) > context_window * 3:  # ~3 chars per token estimate
                        prompt = self.truncate_to_context(prompt, model, reserve_tokens=1500)
                        logger.info(f"Truncated prompt for model {model} (context: {context_window} tokens)")
                    
                    # Make the structured request
                    logger.info(f"Structured generation ({tenant_plan}, {'Managed' if is_managed else 'BYOK'}): {provider_id}/{model}")
                    
                    # Construct Messages (Phase 14: Multimodal)
                    messages = []
                    messages.append({"role": "system", "content": system_instruction})
                    
                    if image_payload:
                        # OpenAI / Compatible Vision Format
                        user_content = [
                            {"type": "text", "text": prompt},
                            {
                                "type": "image_url",
                                "image_url": {
                                    "url": image_payload if image_payload.startswith("http") else f"data:image/jpeg;base64,{image_payload}"
                                }
                            }
                        ]
                        messages.append({"role": "user", "content": user_content})
                        logger.info(f"Adding visual payload to STRUCTURED request for {model}")
                    else:
                        messages.append({"role": "user", "content": prompt})

                    result = instructor_client.chat.completions.create(
                        model=model,
                        response_model=response_model,
                        messages=messages,
                        temperature=temperature,
                        max_tokens=1000
                    )
                    
                    # --- Record Usage (Phase 6.6) ---
                    try:
                        # Instructor attaches usage to the response object metadata
                        usage = getattr(result, "_raw_response", {}).usage if hasattr(result, "_raw_response") else None
                        if not usage and hasattr(result, "usage"):
                             usage = result.usage

                        if usage:
                            input_tokens = usage.prompt_tokens
                            output_tokens = usage.completion_tokens

                            fetcher = get_pricing_fetcher()
                            cost = fetcher.estimate_cost(model, input_tokens, output_tokens)

                            if cost and cost > 0:
                                llm_usage_tracker.record(
                                    workspace_id=self.workspace_id,
                                    provider=provider_id,
                                    model=model,
                                    input_tokens=input_tokens,
                                    output_tokens=output_tokens,
                                    cost_usd=cost,
                                    agent_id=agent_id,
                                    chain_id=chain_id, # Phase 11
                                    complexity=complexity.value,
                                    is_managed_service=is_managed
                                )
                    except Exception as cost_err:
                        logger.warning(f"Could not attribute structured LLM cost: {cost_err}")
                        
                    return result
                except Exception as attempt_err:
                    logger.warning(f"Structured attempt failed for {provider_id}/{model}: {attempt_err}")
                    last_error = attempt_err
                    continue
            
            logger.error(f"All structured providers failed. Last error: {last_error}")
            return None
            
        except Exception as e:
            logger.error(f"Structured generation failed: {e}")
            return None


    async def generate_transcription(
        self,
        file: Any,
        model: str = "whisper-1",
        language: Optional[str] = None,
        prompt: Optional[str] = None,
        response_format: str = "json"
    ) -> Dict[str, Any]:
        """
        Transcribe audio to text using OpenAI Whisper.
        Uses BYOK keys for the 'openai' provider.
        """
        # Whisper is currently only supported via OpenAI provider in this architecture
        provider_id = "openai"
        client = self.async_clients.get(provider_id) or self.clients.get(provider_id)
        
        if not client:
            raise ValueError(f"OpenAI provider not configured for transcription. Please add an API key.")

        try:
            # Use the underlying openai client if it's patched by instructor
            # or use it directly if it's a standard client
            raw_client = getattr(client, "client", client)
            
            response = await raw_client.audio.transcriptions.create(
                model=model,
                file=file,
                language=language,
                prompt=prompt,
                response_format=response_format
            )
            
            # Format response (handle both standard and raw response types)
            text = response.text if hasattr(response, "text") else str(response)
            
            return {
                "text": text,
                "model": model,
                "provider": provider_id
            }
        except Exception as e:
            logger.error(f"Whisper transcription failed: {e}")
            raise

    def get_available_providers(self) -> List[str]:

        """Get list of providers with valid API keys"""
        return list(self.clients.keys())

    def get_routing_info(self, prompt: str, task_type: Optional[str] = None) -> Dict[str, Any]:
        """Get routing decision info without making an API call (useful for UI)"""
        complexity = self.analyze_query_complexity(prompt, task_type)
        try:
            provider_id, model = self.get_optimal_provider(complexity, task_type)
            
            # Try to get dynamic pricing
            estimated_cost = None
            try:
                fetcher = get_pricing_fetcher()
                pricing = fetcher.get_model_price(model)
                if pricing:
                    # Estimate for ~500 token response
                    input_tokens = len(prompt) // 4
                    output_tokens = 500
                    estimated_cost = fetcher.estimate_cost(model, input_tokens, output_tokens)
            except Exception as e:
                logger.warning(f"Cost estimation failed for model {model}: {e}")
                estimated_cost = None
            
            return {
                "complexity": complexity.value,
                "selected_provider": provider_id,
                "selected_model": model,
                "available_providers": self.get_available_providers(),
                "cost_tier": "budget" if provider_id in PROVIDER_TIERS["budget"] else "mid" if provider_id in PROVIDER_TIERS["mid"] else "premium",
                "estimated_cost_usd": estimated_cost
            }
        except ValueError as e:
            return {
                "complexity": complexity.value,
                "error": str(e),
                "available_providers": []
            }

    async def refresh_pricing(self, force: bool = False) -> Dict[str, Any]:
        """Refresh dynamic pricing data from LiteLLM and OpenRouter"""
        try:
            pricing = await refresh_pricing_cache(force=force)
            return {"status": "success", "model_count": len(pricing)}
        except Exception as e:
            logger.error(f"Failed to refresh pricing: {e}")
            return {"status": "error", "message": str(e)}

    def get_provider_comparison(self) -> Dict[str, Any]:
        """Get cost comparison across all providers using dynamic pricing"""
        try:
            fetcher = get_pricing_fetcher()
            return fetcher.compare_providers()
        except Exception as e:
            logger.warning(f"Could not get provider comparison: {e}")
            # Return static fallback
            return {
                "openai": {"avg_cost_per_token": 0.00003, "tier": "premium"},
                "anthropic": {"avg_cost_per_token": 0.000025, "tier": "premium"},
                "deepseek": {"avg_cost_per_token": 0.000002, "tier": "budget"},
                "moonshot": {"avg_cost_per_token": 0.000003, "tier": "budget"},
            }

    def get_cheapest_models(self, limit: int = 5) -> List[Dict[str, Any]]:
        """Get the cheapest models available"""
        try:
            fetcher = get_pricing_fetcher()
            return fetcher.get_cheapest_models(limit=limit)
        except Exception as e:
            logger.warning(f"Could not get cheapest models: {e}")
            return []
    async def _get_coordinated_vision_description(self, image_payload: str, tenant_plan: str, is_managed: bool) -> Optional[str]:
        """
        Calls a vision-only model to extract a semantic description of an image.
        This allows non-vision reasoning models to understand visual context.
        """
        # Pick a vision-only model (Janus)
        # For now, we'll try to use a specialized provider or default to a cheap vision model if Janus isn't configured
        # 1. Try Gemini Flash (Cheapest Vision)
        if "google_flash" in self.clients:
            provider = "google_flash"
            model = "gemini-2.0-flash" if "gemini-2.0" in str(self.clients["google_flash"]) else "gemini-1.5-flash"
        # 2. Try Deepseek / Janus
        elif provider in self.clients:
            provider = "deepseek"
            model = "janus-pro-7b"
        # 3. Last resort - GPT-4o-mini
        else:
            provider = "openai"
            model = "gpt-4o-mini"

        try:
            client = self.clients.get(provider)
            if not client: return None

            logger.info(f"Extracting visual description using {model}...")

            messages = [
                {
                    "role": "system",
                    "content": "You are a visual analysis specialist. Your goal is to describe a browser screenshot for an AI agent that cannot see it. "
                               "For every interactive element (buttons, links, inputs, icons, etc.), you MUST provide: "
                               "1. A name or label. "
                               "2. A brief description of its function. "
                               "3. Its precise coordinates as [x, y] center points on a normalized grid from 0 to 1000 "
                               "(where [0, 0] is top-left and [1000, 1000] is bottom-right). "
                               "Format elements as a clear list. Also describe the overall layout and active notifications."
                },
                {
                    "role": "user",
                    "content": [
                        {"type": "text", "text": "Analyze this screenshot and provide a semantic list of interactive elements with [x, y] coordinates on a 1000x1000 grid."},
                        {
                            "type": "image_url",
                            "image_url": {
                                "url": image_payload if image_payload.startswith("http") else f"data:image/jpeg;base64,{image_payload}"
                            }
                        }
                    ]
                }
            ]

            response = client.chat.completions.create(
                model=model,
                messages=messages,
                max_tokens=500
            )

            desc = response.choices[0].message.content
            return desc
        except Exception as e:
            logger.error(f"Coordinated vision extraction failed: {e}")
            return None

    async def stream_completion(
        self,
        messages: List[Dict],
        model: str,
        provider_id: str,
        temperature: float = 0.7,
        max_tokens: int = 1000,
        agent_id: Optional[str] = None,
        db = None
    ) -> AsyncGenerator[str, None]:
        """
        Stream LLM responses token-by-token with optional governance tracking.

        Includes automatic provider fallback on failure for improved resilience.

        Args:
            messages: Chat messages in OpenAI format
            model: Model name
            provider_id: Provider identifier (e.g., "openai", "deepseek")
            temperature: Sampling temperature
            max_tokens: Maximum tokens to generate
            agent_id: Optional agent ID for governance tracking
            db: Optional database session for governance tracking

        Yields:
            Individual tokens as they arrive from the LLM
        """
        if not self.async_clients and not self.clients:
            raise ValueError("No clients initialized. Streaming unavailable.")

        # Get provider fallback order
        provider_order = self._get_provider_fallback_order(provider_id)

        if not provider_order:
            raise ValueError(f"No available providers for streaming. Requested: {provider_id}")

        # Governance tracking
        governance_enabled = os.getenv("STREAMING_GOVERNANCE_ENABLED", "true").lower() == "true"
        agent_execution = None

        last_error = None

        # Try each provider in fallback order
        for attempt_provider_id in provider_order:
            # Get client for this provider (prefer async, fallback to sync)
            client = self.async_clients.get(attempt_provider_id)
            if not client:
                client = self.clients.get(attempt_provider_id)

            if not client:
                logger.warning(f"No client available for provider: {attempt_provider_id}")
                continue

            logger.info(f"Attempting stream with provider: {attempt_provider_id} (requested: {provider_id})")

            try:
                import time
                request_start = time.time()
                # Create execution record if agent_id provided (only on first attempt)
                if agent_execution is None and agent_id and governance_enabled and db:
                    agent_execution = AgentExecution(
                        agent_id=agent_id,
                        workspace_id=self.workspace_id,
                        status="running",
                        input_summary=f"LLM stream: {model} ({attempt_provider_id})",
                        triggered_by="llm_stream"
                    )
                    db.add(agent_execution)
                    db.commit()
                    db.refresh(agent_execution)

                    logger.debug(f"Created agent execution {agent_execution.id} for LLM stream")

                # Use async streaming API
                stream = await client.chat.completions.create(
                    model=model,
                    messages=messages,
                    temperature=temperature,
                    max_tokens=max_tokens,
                    stream=True
                )

                token_count = 0
                async for chunk in stream:
                    if chunk.choices:
                        delta = chunk.choices[0].delta
                        if hasattr(delta, 'content') and delta.content:
                            token_count += 1
                            yield delta.content

                # Record successful completion
                if agent_execution and governance_enabled and db:
                    try:
                        agent_execution.status = "completed"
                        agent_execution.output_summary = f"Generated {token_count} tokens via {model} ({attempt_provider_id})"
                        agent_execution.completed_at = datetime.now()
                        db.commit()

                        # Record outcome for confidence scoring
                        from core.agent_governance_service import AgentGovernanceService
                        governance = AgentGovernanceService(db)
                        await governance.record_outcome(agent_id, success=True)

                        logger.info(f"Completed LLM stream execution {agent_execution.id} via {attempt_provider_id}")
                    except Exception as tracking_error:
                        logger.error(f"Failed to track LLM stream completion: {tracking_error}")

                # Phase 226.4-04: Record successful streaming API call for health monitoring
                latency_ms = (time.time() - request_start) * 1000
                self.health_monitor.record_call(attempt_provider_id, success=True, latency_ms=latency_ms)

                # Success! Return from the function
                return

            except Exception as e:
                last_error = e
                logger.warning(f"Streaming failed for {attempt_provider_id}/{model}: {e}")

                # Phase 226.4-04: Record failed streaming API call for health monitoring
                try:
                    latency_ms = (time.time() - request_start) * 1000
                    self.health_monitor.record_call(attempt_provider_id, success=False, latency_ms=latency_ms)
                except:
                    pass  # Don't let health monitoring errors affect primary flow

                # If this is not the last provider, try the next one
                if attempt_provider_id != provider_order[-1]:
                    logger.info(f"Falling back to next provider...")
                    continue

                # This was the last provider, fall through to error handling
                break

        # All providers failed - mark execution as failed and yield error
        logger.error(f"All {len(provider_order)} providers failed for {model}. Last error: {last_error}")

        if agent_execution and governance_enabled and db:
            try:
                agent_execution.status = "failed"
                agent_execution.error_message = f"All providers failed. Last: {str(last_error)}"
                agent_execution.completed_at = datetime.now()
                db.commit()

                # Record failure for confidence scoring
                from core.agent_governance_service import AgentGovernanceService
                governance = AgentGovernanceService(db)
                await governance.record_outcome(agent_id, success=False)

            except Exception as tracking_error:
                logger.error(f"Failed to track LLM stream failure: {tracking_error}")

        # Yield final error message
        yield f"\n\n[Error: All LLM providers failed. Last error: {str(last_error)}]"

    async def generate_embedding(
        self,
        text: str,
        model: str,
        provider: str = "openai"
    ) -> List[float]:
        """
        Generate embedding vector for a single text string using managed clients.
        
        Args:
            text: Text to embed
            model: Model identifier
            provider: Provider identifier ("openai" or "cohere")
            
        Returns:
            List of floats representing the embedding vector
        """
        client = self.async_clients.get(provider) or self.clients.get(provider)
        if not client:
            raise ValueError(f"No client available for provider: {provider}")

        logger.info(f"Attempting embedding with provider: {provider} (model: {model})")
        
        try:
            if provider == "openai":
                response = await client.embeddings.create(model=model, input=text)
                return response.data[0].embedding
            elif provider == "cohere":
                # Cohere async client uses .embed()
                response = await client.embed(texts=[text], model=model, input_type="search_document")
                return response.embeddings[0]
            else:
                raise ValueError(f"Provider {provider} does not support embeddings via BYOKHandler yet.")
        except Exception as e:
            logger.error(f"Embedding generation failed for {provider}: {e}")
            raise

    async def generate_embeddings_batch(
        self,
        texts: List[str],
        model: str,
        provider: str = "openai"
    ) -> List[List[float]]:
        """
        Generate embeddings for multiple texts in batch using managed clients.
        """
        client = self.async_clients.get(provider) or self.clients.get(provider)
        if not client:
            raise ValueError(f"No client available for provider: {provider}")

        logger.info(f"Attempting batch embedding with provider: {provider} (model: {model}, count: {len(texts)})")
        
        try:
            if provider == "openai":
                response = await client.embeddings.create(model=model, input=texts)
                return [item.embedding for item in response.data]
            elif provider == "cohere":
                response = await client.embed(texts=texts, model=model, input_type="search_document")
                return [emb for emb in response.embeddings]
            else:
                raise ValueError(f"Provider {provider} does not support batch embeddings via BYOKHandler yet.")
        except Exception as e:
            logger.error(f"Batch embedding generation failed for {provider}: {e}")
            raise

    def classify_cognitive_tier(self, prompt: str, task_type: Optional[str] = None) -> CognitiveTier:
        """
        Classify a query into a cognitive tier using the 5-tier system.

        Phase 68: Wrapper method for CognitiveClassifier to enable easy cognitive
        tier classification from BYOKHandler instances.

        Args:
            prompt: The query text to classify
            task_type: Optional task type hint (code, chat, analysis, etc.)

        Returns:
            CognitiveTier classification for the query

        Example:
            >>> handler = BYOKHandler()
            >>> tier = handler.classify_cognitive_tier("explain quantum computing")
            >>> print(tier.value)  # 'standard' or 'versatile'
        """
        return self.cognitive_classifier.classify(prompt, task_type)

    def _is_trial_restricted(self) -> bool:
        """
        Check if the workspace has trial restrictions.
        Returns False for now (can be enhanced later).
        """
        try:
            with get_db_session() as db:
                workspace = db.query(Workspace).filter(Workspace.id == self.workspace_id).first()
                if workspace and hasattr(workspace, 'trial_ended') and workspace.trial_ended:
                    return True
                return False
        except Exception as e:
            logger.debug(f"Could not check trial restriction: {e}")
            return False