File size: 49,530 Bytes
6d941d9
 
 
 
 
 
 
 
 
 
 
 
 
 
12e0e4a
 
6d941d9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7e83939
 
 
eee14a2
6d941d9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102ea52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6d941d9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102ea52
 
 
 
 
 
6d941d9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102ea52
 
 
 
 
 
 
 
6d941d9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102ea52
 
 
 
 
 
 
 
6d941d9
 
 
 
 
 
 
 
 
102ea52
 
 
 
 
 
 
 
6d941d9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12e0e4a
 
 
 
 
6d941d9
 
 
 
 
 
a625b1a
 
 
6d941d9
 
 
 
 
a625b1a
6d941d9
a625b1a
 
6d941d9
 
 
 
 
 
 
 
 
 
7e83939
 
eee14a2
7e83939
a625b1a
 
7e83939
8f6b7b7
 
 
 
 
 
7e83939
 
eee14a2
 
6d941d9
 
 
7e83939
8f6b7b7
 
6d941d9
 
 
 
7e83939
 
 
 
 
 
 
 
eee14a2
 
 
 
 
7e83939
 
 
 
 
 
 
6d941d9
 
 
 
a625b1a
 
6d941d9
 
 
 
 
102ea52
 
 
 
 
 
 
 
 
 
6d941d9
 
102ea52
6d941d9
 
 
8f6b7b7
7e83939
 
 
eee14a2
 
6d941d9
 
a625b1a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6d941d9
 
 
 
b31ff59
6d941d9
 
 
 
 
 
102ea52
 
 
 
 
 
 
 
 
 
 
b31ff59
102ea52
 
 
 
 
 
 
 
 
 
6d941d9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102ea52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6d941d9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12e0e4a
 
 
 
 
 
 
 
6d941d9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102ea52
 
 
 
 
 
 
 
 
 
6d941d9
 
 
 
 
 
 
 
 
 
 
 
 
 
102ea52
 
 
 
 
 
 
 
 
 
 
 
 
6d941d9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7e83939
 
6d941d9
 
7e83939
 
 
40c4fef
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91f3a1e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7e83939
 
 
6d941d9
eee14a2
 
 
 
 
 
 
 
 
 
 
6d941d9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8f6b7b7
6d941d9
 
 
8f6b7b7
6d941d9
 
8f6b7b7
 
eee14a2
8f6b7b7
 
 
eee14a2
6d941d9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Programa: CPU-only OpenAI-compatible LLM endpoint for the original Voyager agent."""
from __future__ import annotations

import ast
import json
import math
import os
import pathlib
import re
import threading
import time
import uuid
from typing import Any, Iterator

from cpu_backend import cpu_only_report, cpu_only_summary

# The Space is hosted on ZeroGPU hardware only to retain the 16-vCPU / ~97-GiB
# CPU envelope. ZeroGPU requires at least one decorated function. The sentinel
# below is NEVER called by the API or UI, so it never requests GPU time.
import spaces

# Strict CPU mode before llama.cpp is imported.
os.environ["CUDA_VISIBLE_DEVICES"] = ""
os.environ["HIP_VISIBLE_DEVICES"] = ""
os.environ["ROCR_VISIBLE_DEVICES"] = ""
os.environ["GGML_VK_VISIBLE_DEVICES"] = ""
os.environ["GGML_CUDA"] = "0"
os.environ["HF_HUB_DISABLE_PROGRESS_BARS"] = "1"
os.environ["TQDM_DISABLE"] = "1"
os.environ.setdefault("MALLOC_ARENA_MAX", "2")

import gradio as gr
import psutil
import tiktoken
import uvicorn
from fastapi import FastAPI, Header, HTTPException, Request
from fastapi.responses import JSONResponse, StreamingResponse
from huggingface_hub import hf_hub_download
from starlette.concurrency import run_in_threadpool
from minecraft_stack import STACK
from voyager_stack import VOYAGER
from gemini_bot_stack import GEMINI_BOT
from altoclef_stack import ALTOCLEF

MODEL_REPO = os.environ.get(
    "MODEL_REPO",
    "unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF",
)
MODEL_FILE = os.environ.get(
    "MODEL_FILE",
    "Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf",
)
MODEL_REVISION = os.environ.get(
    "MODEL_REVISION",
    "b17cb02dd882d5b6ab62fc777ad2995f19668350",
)
MODEL_ALIAS = os.environ.get("MODEL_ALIAS", "qwen3-coder-30b-a3b-instruct")
MODEL_ALIASES = {
    MODEL_ALIAS,
    "Qwen3-Coder-30B-A3B-Instruct",
    "voyager-qwen3-coder",
    # Kept so an older client or checkpoint that still names the previous brain
    # resolves instead of 404ing on the model name.
    "granite-4.0-h-tiny",
    "Granite-4.0-H-Tiny",
    "voyager-granite-4-h-tiny",
    "qwen2.5-coder-7b-instruct",
    "Qwen2.5-Coder-7B-Instruct",
    "voyager-qwen2.5-coder-7b",
    "qwen2.5-coder-3b-instruct",
    "Qwen2.5-Coder-3B-Instruct",
    "voyager-qwen2.5-coder-3b",
    "qwen3-4b-instruct-2507",
    "voyager-qwen3-4b",
}
# The model is 18.56 GB. huggingface_hub defaults to ~/.cache, which this
# container loses on every rebuild, so a rebuild re-downloaded the whole file
# before the first request could be served. /persist survives, so the download
# happens once.
HF_CACHE_DIR = os.environ.get("HF_CACHE_DIR", "/persist/hf-cache")

AUX_MODEL_REPO = os.environ.get("AUX_MODEL_REPO", "ibm-granite/granite-4.0-h-tiny-GGUF")
AUX_MODEL_FILE = os.environ.get("AUX_MODEL_FILE", "granite-4.0-h-tiny-Q4_K_M.gguf")
AUX_MODEL_REVISION = os.environ.get("AUX_MODEL_REVISION", "08d5a8a9741dd5c1a95d2d39e25253226aa1464e")
AUX_MODEL_ALIAS = os.environ.get("AUX_MODEL_ALIAS", "granite-4.0-h-tiny-aux")
AUX_MODEL_ALIASES = {AUX_MODEL_ALIAS, "voyager-aux-granite-tiny"}
AUX_CTX = int(os.environ.get("AUX_CPU_CTX", "8192"))
AUX_PRELOAD = os.environ.get("AUX_CPU_PRELOAD", "0") == "1"

EMBED_REPO = os.environ.get("EMBED_REPO", "Qwen/Qwen3-Embedding-0.6B-GGUF")
EMBED_FILE = os.environ.get("EMBED_FILE", "Qwen3-Embedding-0.6B-Q8_0.gguf")
EMBED_REVISION = os.environ.get(
    "EMBED_REVISION",
    "370f27d7550e0def9b39c1f16d3fbaa13aa67728",
)
EMBED_ALIAS = os.environ.get("EMBED_ALIAS", "text-embedding-ada-002")
EMBED_CTX = int(os.environ.get("EMBED_CTX", "8192"))
EMBED_BATCH = int(os.environ.get("EMBED_BATCH", "8192"))
PORT = int(os.environ.get("PORT", "7860"))
GRADIO_PORT = int(os.environ.get("GRADIO_PORT", "7862"))
# Qwen3-Coder-30B-A3B declares max_position_embeddings 262144 with
# rope_scaling null, so 32768 needs no YaRN and costs no quality. The previous
# 8192 window was already tight: the Action prompt measured 6909 tokens and the
# model then had to fit a 1000+ token program into what was left.
CTX = int(os.environ.get("CPU_CTX", "32768"))
BATCH = int(os.environ.get("CPU_BATCH", "2048"))
UBATCH = int(os.environ.get("CPU_UBATCH", "512"))
# The Action Agent must be able to emit the official multi-function program.
# A 512-token ceiling truncated valid JavaScript into a parse error, so the cap
# is raised rather than the upstream response contract narrowed. Curriculum,
# Critic and SkillManager still use the smaller default.
MAX_TOKENS_CAP = int(os.environ.get("MAX_TOKENS_CAP", "2048"))
DEFAULT_MAX_TOKENS = int(os.environ.get("DEFAULT_MAX_TOKENS", "384"))
API_KEY = os.environ.get("API_KEY", "").strip()
PLAYER_MODE = os.environ.get("PLAYER_MODE", "0") == "1"
PRELOAD = os.environ.get("CPU_PRELOAD", "1") == "1"

# Server-only mode. The brain lives in the Space `Erinaldordso/Mine`; this Space
# keeps the Minecraft server, the Mineflayer bridge and the Voyager runner, and
# nothing else. Every role the bot needs -- Action, Curriculum, Critic, QA,
# SkillManager -- plus embeddings is served at VOYAGER_BRAIN_BASE_URL.
#
# Measured reason: the local Granite aux and the local Qwen main were both
# loading here even though no caller used them. The Voyager runner sets
# OPENAI_API_BASE to the Mine endpoint, so its chat AND embedding calls already
# left this Space; the only remaining local user was the Gemini bot's embeddings.
# Holding two GGUF models resident for that cost RAM the Java server needs, and
# a failed local load surfaced as `aux_ready: false` in /health, which reads as a
# broken bot when it is only an unused, broken loader.
#
# SERVER_ONLY=1 skips loading them and makes the chat route refuse instead of
# silently starting an 18 GB download on first request.
SERVER_ONLY = os.environ.get("MINECRAFT_SERVER_ONLY", "1") == "1"
BRAIN_BASE_URL = os.environ.get(
    "VOYAGER_BRAIN_BASE_URL", "https://erinaldordso-mine.hf.space/v1"
)

@spaces.GPU(duration=1)
def _zerogpu_compatibility_sentinel():
    # Never invoked. Presence only satisfies the ZeroGPU host scanner.
    return {"gpu_used": False, "inference_backend": "cpu-only"}

PLAYER_SYSTEM = os.environ.get(
    "PLAYER_SYSTEM_PROMPT",
    (
        "Voce e o cerebro estrategico do jogador autonomo iaautono em Minecraft Bedrock Survival. "
        "A cada chamada escolha UMA unica proxima acao curta, segura, verificavel e executavel. "
        "Use somente fatos presentes no estado real recebido; nunca invente item, recurso, entidade ou coordenada. "
        "Prioridade de emergencia: se estiver morto, respawn; se estiver se afogando ou sem ar, sair da agua/subir; "
        "depois preservar vida, comida e equipamentos. "
        "Progressao permanente: madeira -> bancada -> ferramentas de pedra -> fornalha/comida -> ferro -> "
        "escudo/armas/armadura -> base segura e armazenamento -> fazenda irrigada com replantio -> "
        "diamante -> encantamentos/reparo -> Nether -> netherite -> infraestrutura e estoques avancados. "
        "Nao ataque jogadores nem destrua construcoes de terceiros. Nao use comandos administrativos ou cheats. "
        "O controlador local executa fisica, movimento, mineracao, crafting, colocacao, inventario, combate contra mobs, "
        "respawn e reconexao; voce decide apenas o objetivo imediato. "
        "Se uma acao anterior falhou, escolha uma alternativa segura em vez de repetir cegamente. "
        "Nunca declare sucesso sem evidencia no estado. Nao fique ocioso se houver progresso survival seguro possivel. "
        "Responda estritamente no formato solicitado pelo cliente e mantenha o motivo curto."
    ),
)

def _read_text(path: str) -> str:
    try:
        return pathlib.Path(path).read_text().strip()
    except Exception:
        return ""

def _cgroup_cpu_quota() -> int | None:
    raw = _read_text("/sys/fs/cgroup/cpu.max")
    if raw:
        parts = raw.split()
        if len(parts) >= 2 and parts[0] != "max":
            try:
                q, period = int(parts[0]), int(parts[1])
                if q > 0 and period > 0:
                    return max(1, int(math.floor(q / period)))
            except Exception:
                pass
    return None

def _affinity_cpus() -> int:
    try:
        return max(1, len(os.sched_getaffinity(0)))
    except Exception:
        return max(1, os.cpu_count() or 1)

def effective_cpus() -> int:
    affinity = _affinity_cpus()
    quota = _cgroup_cpu_quota()
    return min(affinity, quota) if quota else affinity

def _memory_limit_gib() -> float | None:
    for path in ("/sys/fs/cgroup/memory.max", "/sys/fs/cgroup/memory/memory.limit_in_bytes"):
        raw = _read_text(path)
        if raw and raw != "max":
            try:
                value = int(raw)
                if 0 < value < (1 << 60):
                    return round(value / (1024**3), 2)
            except Exception:
                pass
    return None

THREADS = int(os.environ.get("CPU_THREADS", "16"))
THREADS = max(1, min(THREADS, effective_cpus()))
THREADS_BATCH = int(os.environ.get("CPU_THREADS_BATCH", "16"))
THREADS_BATCH = max(1, min(THREADS_BATCH, effective_cpus()))
FLASH_ATTN = os.environ.get("CPU_FLASH_ATTN", "1") == "1"
CGROUP_CPU_QUOTA = _cgroup_cpu_quota()
MEMORY_LIMIT_GIB = _memory_limit_gib()
os.environ["OMP_NUM_THREADS"] = str(THREADS)
os.environ["OPENBLAS_NUM_THREADS"] = str(THREADS)
os.environ["MKL_NUM_THREADS"] = str(THREADS)
os.environ["NUMEXPR_NUM_THREADS"] = str(THREADS)
os.environ["OMP_DYNAMIC"] = "FALSE"

_model = None
_model_path: str | None = None
_model_error = ""
_model_load_seconds = 0.0
_model_loaded_at = 0.0
_loading = False
_load_lock = threading.Lock()
_llm_lock = threading.Lock()

_aux_model = None
_aux_model_path: str | None = None
_aux_model_error = ""
_aux_model_load_seconds = 0.0
_aux_model_loaded_at = 0.0
_aux_loading = False
_aux_load_lock = threading.Lock()

_embed_model = None
_embed_model_path: str | None = None
_embed_error = ""
_embed_loading = False
_embed_load_seconds = 0.0
_embed_load_lock = threading.Lock()
_embed_lock = threading.Lock()
_token_decoder = None

def _load_model() -> None:
    global _model, _model_path, _model_error, _model_load_seconds, _model_loaded_at, _loading
    if SERVER_ONLY:
        # No local brain in this Space. See the SERVER_ONLY note above.
        raise RuntimeError(
            "this Space runs the Minecraft server only; the brain is at "
            f"{BRAIN_BASE_URL}"
        )
    if _model is not None:
        return
    with _load_lock:
        if _model is not None:
            return
        _loading = True
        started = time.perf_counter()
        try:
            print(
                f"[cpu] loading {MODEL_REPO}/{MODEL_FILE} threads={THREADS} "
                f"ctx={CTX} batch={BATCH}/{UBATCH} n_gpu_layers=0",
                flush=True,
            )
            _model_path = hf_hub_download(
                repo_id=MODEL_REPO,
                filename=MODEL_FILE,
                revision=MODEL_REVISION,
                cache_dir=HF_CACHE_DIR,
            )
            from llama_cpp import Llama
            _model = Llama(
                model_path=_model_path,
                n_ctx=CTX,
                n_threads=THREADS,
                n_threads_batch=THREADS_BATCH,
                n_gpu_layers=0,
                n_batch=BATCH,
                n_ubatch=UBATCH,
                flash_attn=FLASH_ATTN,
                offload_kqv=False,
                op_offload=False,
                use_mmap=True,
                use_mlock=False,
                numa=False,
                no_perf=True,
                verbose=False,
            )
            _model_load_seconds = time.perf_counter() - started
            _model_loaded_at = time.time()
            _model_error = ""
            print(f"[cpu] ready load_seconds={_model_load_seconds:.2f}", flush=True)
        except Exception as exc:
            _model_error = repr(exc)
            print(f"[cpu] load failed: {_model_error}", flush=True)
        finally:
            _loading = False

def _get_model(timeout: float = 900.0):
    if _model is not None:
        return _model
    if not _loading:
        _load_model()
    deadline = time.time() + timeout
    while time.time() < deadline:
        if _model is not None:
            return _model
        if not _loading and _model_error:
            raise RuntimeError(_model_error)
        time.sleep(0.25)
    raise RuntimeError("model readiness timeout")


def _load_aux_model() -> None:
    global _aux_model, _aux_model_path, _aux_model_error, _aux_model_load_seconds, _aux_model_loaded_at, _aux_loading
    if SERVER_ONLY:
        # Granite used to be resident here as the QA/SkillManager model. Those
        # roles are served by the Mine Space now, so this loader is dead code
        # and is refused rather than left to download 2 GB on a stray request.
        raise RuntimeError(
            "this Space runs the Minecraft server only; the auxiliary model is "
            f"served at {BRAIN_BASE_URL}"
        )
    if _aux_model is not None:
        return
    with _aux_load_lock:
        if _aux_model is not None:
            return
        _aux_loading = True
        started = time.perf_counter()
        try:
            print(
                f"[cpu-aux] loading {AUX_MODEL_REPO}/{AUX_MODEL_FILE} threads={THREADS} "
                f"ctx={AUX_CTX} batch={BATCH}/{UBATCH} n_gpu_layers=0",
                flush=True,
            )
            _aux_model_path = hf_hub_download(
                repo_id=AUX_MODEL_REPO,
                filename=AUX_MODEL_FILE,
                revision=AUX_MODEL_REVISION,
                cache_dir=HF_CACHE_DIR,
            )
            from llama_cpp import Llama
            _aux_model = Llama(
                model_path=_aux_model_path,
                n_ctx=AUX_CTX,
                n_threads=THREADS,
                n_threads_batch=THREADS_BATCH,
                n_gpu_layers=0,
                n_batch=BATCH,
                n_ubatch=UBATCH,
                flash_attn=FLASH_ATTN,
                offload_kqv=False,
                op_offload=False,
                use_mmap=True,
                use_mlock=False,
                numa=False,
                no_perf=True,
                verbose=False,
            )
            _aux_model_load_seconds = time.perf_counter() - started
            _aux_model_loaded_at = time.time()
            _aux_model_error = ""
            print(f"[cpu-aux] ready load_seconds={_aux_model_load_seconds:.2f}", flush=True)
        except Exception as exc:
            _aux_model_error = repr(exc)
            print(f"[cpu-aux] load failed: {_aux_model_error}", flush=True)
        finally:
            _aux_loading = False


def _get_aux_model(timeout: float = 300.0):
    if _aux_model is not None:
        return _aux_model
    if not _aux_loading:
        _load_aux_model()
    deadline = time.time() + timeout
    while time.time() < deadline:
        if _aux_model is not None:
            return _aux_model
        if not _aux_loading and _aux_model_error:
            raise RuntimeError(_aux_model_error)
        time.sleep(0.25)
    raise RuntimeError("aux model readiness timeout")


def _select_chat_model(requested_model: str):
    if SERVER_ONLY:
        # Fail with a redirect instead of a load attempt. A caller that still
        # names a chat model here is pointed at the Space that actually serves
        # it, so the misroute is obvious in the response body and in the log.
        raise RuntimeError(
            f"chat is not served by this Space (model requested: {requested_model}); "
            f"the brain is at {BRAIN_BASE_URL}"
        )
    if requested_model in AUX_MODEL_ALIASES:
        return _get_aux_model(), "aux"
    if requested_model in MODEL_ALIASES:
        return _get_model(), "main"
    raise ValueError(f"Unknown model: {requested_model}")


def _load_embed_model() -> None:
    global _embed_model, _embed_model_path, _embed_error, _embed_loading, _embed_load_seconds
    if SERVER_ONLY:
        # Embeddings moved to the Mine Space with the rest of the brain. Both
        # Spaces load the same Qwen3-Embedding-0.6B and both return 1024-dim
        # vectors, so the existing Chroma indexes stay valid.
        raise RuntimeError(
            "this Space runs the Minecraft server only; embeddings are served at "
            f"{BRAIN_BASE_URL}"
        )
    if _embed_model is not None:
        return
    with _embed_load_lock:
        if _embed_model is not None:
            return
        _embed_loading = True
        started = time.perf_counter()
        try:
            _embed_model_path = hf_hub_download(
                repo_id=EMBED_REPO,
                filename=EMBED_FILE,
                revision=EMBED_REVISION,
                cache_dir=HF_CACHE_DIR,
            )
            from llama_cpp import Llama
            from llama_cpp import llama_cpp as llama_backend
            _embed_model = Llama(
                model_path=_embed_model_path,
                n_ctx=EMBED_CTX,
                n_threads=THREADS,
                n_threads_batch=THREADS_BATCH,
                n_gpu_layers=0,
                n_batch=EMBED_BATCH,
                n_ubatch=EMBED_BATCH,
                embedding=True,
                pooling_type=llama_backend.LLAMA_POOLING_TYPE_LAST,
                flash_attn=True,
                offload_kqv=False,
                op_offload=False,
                use_mmap=True,
                use_mlock=False,
                numa=False,
                verbose=False,
            )
            _embed_load_seconds = time.perf_counter() - started
            _embed_error = ""
            print(f"[cpu] embedding ready load_seconds={_embed_load_seconds:.2f}", flush=True)
        except Exception as exc:
            _embed_error = repr(exc)
            print(f"[cpu] embedding load failed: {_embed_error}", flush=True)
        finally:
            _embed_loading = False

def _get_embed_model(timeout: float = 300.0):
    if _embed_model is not None:
        return _embed_model
    if not _embed_loading:
        _load_embed_model()
    deadline = time.time() + timeout
    while time.time() < deadline:
        if _embed_model is not None:
            return _embed_model
        if not _embed_loading and _embed_error:
            raise RuntimeError(_embed_error)
        time.sleep(0.25)
    raise RuntimeError("embedding model readiness timeout")

def _auth(authorization: str | None) -> None:
    if API_KEY and authorization != f"Bearer {API_KEY}":
        raise HTTPException(status_code=401, detail="Invalid API key")

def _get_token_decoder():
    global _token_decoder
    if _token_decoder is None:
        _token_decoder = tiktoken.get_encoding("cl100k_base")
    return _token_decoder

def _normalize_embedding_inputs(inputs: Any) -> list[str]:
    if isinstance(inputs, str):
        return [inputs]
    if isinstance(inputs, list) and all(isinstance(item, str) for item in inputs):
        return inputs

    decoder = _get_token_decoder()
    if isinstance(inputs, list) and all(isinstance(item, int) for item in inputs):
        return [decoder.decode(inputs)]
    if isinstance(inputs, list) and all(
        isinstance(item, list) and all(isinstance(token, int) for token in item)
        for item in inputs
    ):
        return [decoder.decode(item) for item in inputs]

    raise HTTPException(
        status_code=400,
        detail="input must be a string, list of strings, token list, or list of token lists",
    )

def _messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
    out: list[dict[str, Any]] = []
    for raw_message in messages:
        if not isinstance(raw_message, dict):
            continue
        message = dict(raw_message)
        tool_calls = message.get("tool_calls")
        if isinstance(tool_calls, list):
            normalized_calls: list[dict[str, Any]] = []
            for raw_call in tool_calls:
                if not isinstance(raw_call, dict):
                    continue
                call = dict(raw_call)
                function = call.get("function")
                if isinstance(function, dict):
                    function = dict(function)
                    arguments = function.get("arguments")
                    if isinstance(arguments, str):
                        try:
                            parsed_arguments = json.loads(arguments)
                            if isinstance(parsed_arguments, dict):
                                function["arguments"] = parsed_arguments
                        except json.JSONDecodeError:
                            pass
                    call["function"] = function
                normalized_calls.append(call)
            message["tool_calls"] = normalized_calls
        out.append(message)
    if PLAYER_MODE:
        if out and out[0].get("role") == "system":
            out[0]["content"] = PLAYER_SYSTEM + "\n\n" + str(out[0].get("content") or "")
        else:
            out.insert(0, {"role": "system", "content": PLAYER_SYSTEM})
    return out

def _kwargs(payload: dict[str, Any], stream: bool) -> dict[str, Any]:
    max_tokens = payload.get("max_tokens", payload.get("max_completion_tokens", DEFAULT_MAX_TOKENS))
    out: dict[str, Any] = {
        "messages": _messages(payload.get("messages") or []),
        "max_tokens": max(1, min(int(max_tokens), MAX_TOKENS_CAP)),
        "temperature": float(payload.get("temperature", 0.0)),
        "top_p": float(payload.get("top_p", 1.0)),
        "top_k": int(payload.get("top_k", 0)),
        "min_p": float(payload.get("min_p", 0.0)),
        "presence_penalty": float(payload.get("presence_penalty", 0.0)),
        "stream": stream,
    }
    for key in ("tools", "tool_choice", "stop", "response_format", "seed"):
        if key in payload and payload[key] is not None:
            out[key] = payload[key]
    if PLAYER_MODE and not payload.get("tools") and "response_format" not in out:
        out["response_format"] = {"type": "json_object"}
    return out

def _tool_argument_config(tools: list[dict[str, Any]] | None, function_name: str) -> dict[str, Any]:
    for tool in tools or []:
        if not isinstance(tool, dict) or tool.get("type") != "function":
            continue
        function = tool.get("function")
        if not isinstance(function, dict) or function.get("name") != function_name:
            continue
        parameters = function.get("parameters")
        if isinstance(parameters, dict):
            properties = parameters.get("properties", parameters)
            return properties if isinstance(properties, dict) else {}
    return {}


def _convert_tool_value(value: str, name: str, config: dict[str, Any]) -> Any:
    spec = config.get(name, {}) if isinstance(config, dict) else {}
    param_type = spec.get("type", "string") if isinstance(spec, dict) else "string"
    if isinstance(param_type, list):
        param_type = next((item for item in param_type if item != "null"), "string")
    param_type = str(param_type).lower()
    if param_type == "null":
        return None
    if param_type.startswith("int"):
        try:
            return int(value)
        except (TypeError, ValueError):
            return value
    if param_type.startswith(("num", "float")):
        try:
            number = float(value)
            return int(number) if number.is_integer() else number
        except (TypeError, ValueError):
            return value
    if param_type in {"boolean", "bool", "binary"}:
        lowered = value.lower()
        return lowered == "true" if lowered in {"true", "false"} else False
    if param_type in {"object", "array", "arr"} or param_type.startswith(("dict", "list")):
        try:
            return json.loads(value)
        except (TypeError, ValueError, json.JSONDecodeError):
            try:
                return ast.literal_eval(value)
            except (ValueError, SyntaxError):
                return value
    return value


_TOOL_CALL_RE = re.compile(r"<tool_call>(.*?)</tool_call>|<tool_call>(.*?)$", re.DOTALL)
_TOOL_FUNCTION_RE = re.compile(r"<function=(.*?)</function>|<function=(.*)$", re.DOTALL)
_TOOL_PARAMETER_RE = re.compile(
    r"<parameter=(.*?)(?:</parameter>|(?=<parameter=)|(?=</function>)|$)",
    re.DOTALL,
)


def _parse_qwen3_coder_tool_calls(
    model_output: str,
    tools: list[dict[str, Any]] | None,
) -> tuple[str | None, list[dict[str, Any]]]:
    # Mirrors Qwen's official qwen3coder_tool_parser semantics for non-streaming output.
    if "<function=" not in model_output:
        return model_output, []

    matches = _TOOL_CALL_RE.findall(model_output)
    raw_tool_calls = [left if left else right for left, right in matches] or [model_output]

    raw_functions: list[str] = []
    for raw_tool_call in raw_tool_calls:
        for complete, partial in _TOOL_FUNCTION_RE.findall(raw_tool_call):
            raw_functions.append(complete if complete else partial)

    parsed_calls: list[dict[str, Any]] = []
    for function_call in raw_functions:
        if ">" not in function_call:
            continue
        function_name, parameters = function_call.split(">", 1)
        function_name = function_name.strip()
        if not function_name:
            continue
        config = _tool_argument_config(tools, function_name)
        arguments: dict[str, Any] = {}
        for match_text in _TOOL_PARAMETER_RE.findall(parameters):
            if ">" not in match_text:
                continue
            param_name, param_value = match_text.split(">", 1)
            if param_value.startswith("\n"):
                param_value = param_value[1:]
            if param_value.endswith("\n"):
                param_value = param_value[:-1]
            param_name = param_name.strip()
            arguments[param_name] = _convert_tool_value(param_value, param_name, config)
        parsed_calls.append(
            {
                "id": f"call_{uuid.uuid4().hex[:24]}",
                "type": "function",
                "function": {
                    "name": function_name,
                    "arguments": json.dumps(arguments, ensure_ascii=False),
                },
            }
        )

    if not parsed_calls:
        return model_output, []

    content_index = model_output.find("<tool_call>")
    if content_index < 0:
        content_index = model_output.find("<function=")
    content = model_output[:content_index] if content_index >= 0 else ""
    return content if content else None, parsed_calls


def _nonstream(payload: dict[str, Any]) -> dict[str, Any]:
    requested_model = str(payload.get("model") or MODEL_ALIAS)
    llm, model_role = _select_chat_model(requested_model)
    started = time.perf_counter()
    with _llm_lock:
        print(f"[chat] start role={model_role} model={requested_model} temperature={payload.get('temperature', 0.0)}", flush=True)
        result = llm.create_chat_completion(**_kwargs(payload, False))
    result = dict(result)
    if payload.get("tools") and result.get("choices"):
        choice = result["choices"][0]
        message = choice.get("message") or {}
        content = message.get("content")
        if isinstance(content, str):
            parsed_content, tool_calls = _parse_qwen3_coder_tool_calls(
                content,
                payload.get("tools"),
            )
            if tool_calls:
                message["content"] = parsed_content
                message["tool_calls"] = tool_calls
                choice["message"] = message
                choice["finish_reason"] = "tool_calls"
    result["model"] = requested_model
    result["backend"] = "cpu-only"
    result["program_metrics"] = {
        "elapsed_seconds": round(time.perf_counter() - started, 4),
        "threads": THREADS,
        "context": AUX_CTX if model_role == "aux" else CTX,
        "model_role": model_role,
        "n_gpu_layers": 0,
    }
    print(f"[chat] done role={model_role} model={requested_model} metrics={result['program_metrics']} usage={result.get('usage', {})}", flush=True)
    return result

def _stream(payload: dict[str, Any]) -> Iterator[str]:
    requested_model = str(payload.get("model") or MODEL_ALIAS)
    llm, _model_role = _select_chat_model(requested_model)
    with _llm_lock:
        for chunk in llm.create_chat_completion(**_kwargs(payload, True)):
            item = dict(chunk)
            item["model"] = requested_model
            item["backend"] = "cpu-only"
            yield "data: " + json.dumps(item, ensure_ascii=False) + "\n\n"
    yield "data: [DONE]\n\n"

# Computed once: the answer cannot change while the process lives, and the
# status endpoint must not walk the package directory on every request.
_CPU_ONLY = cpu_only_report()
print(f"[cpu] {cpu_only_summary(_CPU_ONLY)}", flush=True)

api = FastAPI(title="Programa Voyager OpenAI Endpoint", version="6.0.0")

_supervisor_lock = threading.Lock()
_supervisor_recoveries = 0
_supervisor_last_action = "startup"
_supervisor_last_check = 0.0
# Set during shutdown BEFORE any save/stop, so the 30s loop cannot restart a
# bot or the Java server mid-shutdown. All supervisor sleeps wait on it.
_supervisor_stop = threading.Event()


def _service_supervisor() -> None:
    global _supervisor_recoveries, _supervisor_last_action, _supervisor_last_check
    backoff = 10
    while not _supervisor_stop.is_set():
        try:
            if _supervisor_stop.wait(30):
                break
            _supervisor_last_check = time.time()
            mc = STACK.status()
            processes = mc.get("processes") or {}
            critical = ("minecraft", "viaproxy", "friends")
            dead = [name for name in critical if name in processes and not processes[name].get("running")]
            missing = [name for name in critical if mc.get("state") == "running" and name not in processes]
            if mc.get("state") == "error" or dead or missing:
                with _supervisor_lock:
                    _supervisor_recoveries += 1
                    _supervisor_last_action = f"minecraft recovery #{_supervisor_recoveries}: dead={dead} missing={missing} state={mc.get('state')}"
                    VOYAGER.stop()
                    GEMINI_BOT.stop()
                    ALTOCLEF.stop()
                    STACK.stop()
                    if _supervisor_stop.wait(backoff):
                        break
                    STACK.start_async()
                    # Voyager stays available on demand but never auto-starts:
                    # VOYAGER_AUTOSTART defaults to 0 (AltoClef is the default
                    # controller). The recovery path must respect the same
                    # flag, otherwise a restart silently re-enables it.
                    if os.environ.get("VOYAGER_AUTOSTART", "0") == "1":
                        VOYAGER.start_async()
                    if os.environ.get("GEMINI_BOT_AUTOSTART", "1") == "1":
                        GEMINI_BOT.start_async()
                    if os.environ.get("ALTOCLEF_AUTOSTART", "1") == "1":
                        ALTOCLEF.start_async()
                backoff = min(backoff * 2, 120)
                continue

            vs = VOYAGER.status()
            if (os.environ.get("VOYAGER_AUTOSTART", "0") == "1"
                    and mc.get("state") == "running" and vs.get("state") in {"error", "exited", "stopped"} and not vs.get("running")):
                with _supervisor_lock:
                    _supervisor_recoveries += 1
                    _supervisor_last_action = f"voyager recovery #{_supervisor_recoveries}: state={vs.get('state')}"
                    VOYAGER.start_async()
                backoff = min(backoff * 2, 120)
                continue

            gs = GEMINI_BOT.status()
            if (os.environ.get("GEMINI_BOT_AUTOSTART", "1") == "1" and mc.get("state") == "running"
                    and gs.get("state") in {"error", "stopped"} and not gs.get("running")):
                GEMINI_BOT.start_async()

            als = ALTOCLEF.status()
            if (os.environ.get("ALTOCLEF_AUTOSTART", "1") == "1" and mc.get("state") == "running"
                    and als.get("state") in {"error", "stopped"} and not als.get("running")):
                ALTOCLEF.start_async()

            if mc.get("access_state") == "degraded":
                # nether2rak already retries its Xbox session internally. Report
                # loss of access without restarting the Minecraft world or bot.
                _supervisor_last_action = "xbox session degraded; relay retrying internally"
                continue

            if mc.get("state") == "running" and vs.get("running"):
                backoff = 10
                _supervisor_last_action = "healthy"
        except Exception as exc:
            _supervisor_last_action = f"supervisor exception: {type(exc).__name__}: {str(exc)[:160]}"
            if _supervisor_stop.wait(min(backoff, 120)):
                break
            backoff = min(backoff * 2, 120)


@api.on_event("startup")
def _startup() -> None:
    if SERVER_ONLY:
        # Only the Minecraft server, the Mineflayer bridge and the Voyager
        # runner start here. No local GGUF is loaded and no chat preload thread
        # is spawned, so the RAM budget belongs to Java.
        print(
            "[startup] server-only mode: no local model will be loaded; the "
            f"brain is at {BRAIN_BASE_URL}",
            flush=True,
        )
    if PRELOAD and not SERVER_ONLY:
        threading.Thread(target=_load_model, daemon=True, name="cpu-model-preload").start()
        threading.Thread(target=_load_embed_model, daemon=True, name="cpu-embedding-preload").start()
    if AUX_PRELOAD and not SERVER_ONLY:
        threading.Thread(target=_load_aux_model, daemon=True, name="cpu-aux-model-preload").start()
    if os.environ.get("MINECRAFT_AUTOSTART", "1") == "1":
        STACK.start_async()
    if os.environ.get("VOYAGER_AUTOSTART", "0") == "1":
        VOYAGER.start_async()
    if os.environ.get("GEMINI_BOT_AUTOSTART", "1") == "1":
        GEMINI_BOT.start_async()
    if os.environ.get("ALTOCLEF_AUTOSTART", "1") == "1":
        ALTOCLEF.start_async()
    threading.Thread(target=_service_supervisor, daemon=True, name="service-supervisor").start()

@api.on_event("shutdown")
def _shutdown() -> None:
    # Graceful stop on SIGTERM/redeploy: stop the supervisor FIRST so the 30s
    # loop cannot restart a bot or Java mid-shutdown, then flush the world and
    # stop the Java server (vanilla saves again on "stop"). Best-effort and
    # local only; no remote command endpoint is exposed for this.
    _supervisor_stop.set()
    try:
        STACK.save_all(timeout=20.0)
    except Exception:
        pass
    for runner in (VOYAGER, GEMINI_BOT, ALTOCLEF):
        try:
            runner.stop()
        except Exception:
            pass
    try:
        STACK.stop()
    except Exception:
        pass


@api.get("/")
def root():
    return {
        "status": "ok",
        "service": "Minecraft server and autonomous bot runners" if SERVER_ONLY else "CPU model endpoint",
        "api": "/v1",
        "health": "/health",
    }

@api.get("/health")
def health():
    if SERVER_ONLY:
        # No local weights exist, so there is nothing to report as ready or
        # broken. Reporting `aux_ready: false` here (the old behaviour) was
        # actively misleading: it reads as a failed load when the truth is that
        # no local load is intended. The role map points at the real host.
        return {
            "status": "ok",
            "mode": "server-only",
            "server_only": True,
            "brain_base_url": BRAIN_BASE_URL,
            "local_models": False,
            "goal": os.environ.get("MINECRAFT_GAME_GOAL") or None,
            "roles": {
                "chat": f"{BRAIN_BASE_URL}/chat/completions",
                "embeddings": f"{BRAIN_BASE_URL}/embeddings",
            },
            "backend": "cpu-only",
            "n_gpu_layers": 0,
            "supervisor_recoveries": _supervisor_recoveries,
            "supervisor_last_action": _supervisor_last_action,
            "supervisor_last_check": _supervisor_last_check or None,
        }
    return {
        "status": "ok",
        "ready": _model is not None,
        "loading": _loading,
        "model": MODEL_ALIAS,
        "aux_model": AUX_MODEL_ALIAS,
        "aux_ready": _aux_model is not None,
        "aux_loading": _aux_loading,
        "aux_error": _aux_model_error or None,
        "embedding_model": EMBED_ALIAS,
        "embedding_ready": _embed_model is not None,
        "embedding_loading": _embed_loading,
        "embedding_error": _embed_error or None,
        "backend": "cpu-only",
        "n_gpu_layers": 0,
        "supervisor_recoveries": _supervisor_recoveries,
        "supervisor_last_action": _supervisor_last_action,
        "supervisor_last_check": _supervisor_last_check or None,
        "error": _model_error or None,
    }

@api.get("/status")
def status():
    memory = psutil.virtual_memory()
    process = psutil.Process()
    if SERVER_ONLY:
        return {
            "status": "ok",
            "mode": "server-only",
            "server_only": True,
            "brain_base_url": BRAIN_BASE_URL,
            "local_models": False,
            "roles": {
                "chat": f"{BRAIN_BASE_URL}/chat/completions",
                "embeddings": f"{BRAIN_BASE_URL}/embeddings",
            },
            "backend": "cpu-only",
            "cpu_only": _CPU_ONLY["cpu_only"],
            "gpu_offload_supported": _CPU_ONLY["gpu_offload_supported"],
            "gpu_backend_libraries": _CPU_ONLY["gpu_backend_libraries"],
            "cuda_libraries_present": _CPU_ONLY["cuda_libraries_present"],
            "gpu_enabled": bool(_CPU_ONLY["gpu_offload_supported"] is True),
            "n_gpu_layers": _CPU_ONLY["n_gpu_layers_requested"],
            "threads": THREADS,
            "threads_batch": THREADS_BATCH,
            "effective_cpus": effective_cpus(),
            "cgroup_cpu_quota": CGROUP_CPU_QUOTA,
            "player_mode": PLAYER_MODE,
            "memory_limit_gib": MEMORY_LIMIT_GIB,
            "system_memory_total_gib": round(memory.total / (1024**3), 2),
            "system_memory_available_gib": round(memory.available / (1024**3), 2),
            "process_rss_gib": round(process.memory_info().rss / (1024**3), 2),
        }
    return {
        "status": "ok",
        "ready": _model is not None,
        "loading": _loading,
        "model": MODEL_ALIAS,
        "model_repo": MODEL_REPO,
        "model_file": MODEL_FILE,
        "model_revision": MODEL_REVISION,
        "quantization": "Q4_K_M",
        "model_family": "Granite-4.0-H-Tiny",
        "total_params_b": 7.0,
        "active_params_b": 1.0,
        "reasoning_policy": "voyager-granite-h-tiny-cpu-low-latency",
        "aux_model": AUX_MODEL_ALIAS,
        "aux_model_repo": AUX_MODEL_REPO,
        "aux_model_file": AUX_MODEL_FILE,
        "aux_model_revision": AUX_MODEL_REVISION,
        "aux_context": AUX_CTX,
        "aux_ready": _aux_model is not None,
        "aux_loading": _aux_loading,
        "aux_load_seconds": round(_aux_model_load_seconds, 3),
        "aux_loaded_at": _aux_model_loaded_at or None,
        "aux_error": _aux_model_error or None,
        "embedding_repo": EMBED_REPO,
        "embedding_file": EMBED_FILE,
        "embedding_revision": EMBED_REVISION,
        "embedding_model": EMBED_ALIAS,
        "embedding_ready": _embed_model is not None,
        "embedding_loading": _embed_loading,
        "embedding_load_seconds": round(_embed_load_seconds, 3),
        "embedding_error": _embed_error or None,
        "backend": "cpu-only",
        # Derived from the linked binary and the backend libraries actually
        # present, not a literal. See cpu_backend.cpu_only_report().
        "cpu_only": _CPU_ONLY["cpu_only"],
        "gpu_offload_supported": _CPU_ONLY["gpu_offload_supported"],
        "gpu_backend_libraries": _CPU_ONLY["gpu_backend_libraries"],
        "cuda_libraries_present": _CPU_ONLY["cuda_libraries_present"],
        "gpu_enabled": bool(_CPU_ONLY["gpu_offload_supported"] is True),
        "n_gpu_layers": _CPU_ONLY["n_gpu_layers_requested"],
        "context": CTX,
        "context_mode": "native-no-yarn",
        "threads": THREADS,
        "threads_batch": THREADS_BATCH,
        "effective_cpus": effective_cpus(),
        "cgroup_cpu_quota": CGROUP_CPU_QUOTA,
        "batch": BATCH,
        "ubatch": UBATCH,
        "flash_attn": FLASH_ATTN,
        "aux_context": AUX_CTX,
        "default_max_tokens": DEFAULT_MAX_TOKENS,
        "max_tokens_cap": MAX_TOKENS_CAP,
        "player_mode": PLAYER_MODE,
        "memory_limit_gib": MEMORY_LIMIT_GIB,
        "system_memory_total_gib": round(memory.total / (1024**3), 2),
        "system_memory_available_gib": round(memory.available / (1024**3), 2),
        "process_rss_gib": round(process.memory_info().rss / (1024**3), 2),
        "model_load_seconds": round(_model_load_seconds, 3),
        "model_loaded_at": _model_loaded_at or None,
        "model_error": _model_error or None,
    }

@api.get("/v1/models")
def models(authorization: str | None = Header(default=None)):
    _auth(authorization)
    now = int(time.time())
    if SERVER_ONLY:
        # Advertising a chat model this Space will refuse to serve sends clients
        # to the wrong host on the strength of a successful discovery call. The
        # list stays empty and the response names the real endpoint.
        return {
            "object": "list",
            "data": [],
            "server_only": True,
            "brain_base_url": BRAIN_BASE_URL,
        }
    return {
        "object": "list",
        "data": [
            *[
                {"id": alias, "object": "model", "created": now, "owned_by": "Qwen"}
                for alias in sorted(MODEL_ALIASES | AUX_MODEL_ALIASES)
            ],
            {"id": EMBED_ALIAS, "object": "model", "created": now, "owned_by": "Qwen"},
        ],
    }

@api.post("/v1/embeddings")
async def embeddings(request: Request, authorization: str | None = Header(default=None)):
    _auth(authorization)
    if SERVER_ONLY:
        # Refuse before parsing the body. Without this the request would fall
        # through to `_get_embed_model()`, which raises from `_load_embed_model`
        # and is reported as a generic 503 -- a status a caller reads as "the
        # server is briefly busy, retry me" rather than "you are calling the
        # wrong host". The detail names the right host instead.
        raise HTTPException(
            status_code=503,
            detail=(
                "this Space runs the Minecraft server only; embeddings are "
                f"served at {BRAIN_BASE_URL}"
            ),
        )
    try:
        payload = await request.json()
    except Exception as exc:
        raise HTTPException(status_code=400, detail=f"Invalid JSON: {str(exc)[:160]}") from exc
    inputs = payload.get("input")
    texts = _normalize_embedding_inputs(inputs)
    if not texts:
        raise HTTPException(status_code=400, detail="input must not be empty")
    requested_model = str(payload.get("model") or EMBED_ALIAS)
    try:
        model = await run_in_threadpool(_get_embed_model)
        def _run_embedding():
            with _embed_lock:
                vectors, token_count = model.embed(texts, normalize=True, return_count=True)
            return {
                "object": "list",
                "data": [
                    {"object": "embedding", "embedding": vector, "index": index}
                    for index, vector in enumerate(vectors)
                ],
                "model": requested_model,
                "usage": {"prompt_tokens": token_count, "total_tokens": token_count},
            }
        return JSONResponse(await run_in_threadpool(_run_embedding))
    except RuntimeError as exc:
        raise HTTPException(status_code=503, detail=str(exc)[:800]) from exc


@api.get("/minecraft/status")
def minecraft_status():
    data = STACK.status().copy()
    data.pop("microsoft_device_code", None)
    data.pop("log_tail", None)
    return data

@api.get("/voyager/status")
def voyager_status():
    data = VOYAGER.status().copy()
    data.pop("log_tail", None)
    return data


@api.get("/voyager/log")
def voyager_log(lines: int = 200, find: str | None = None):
    """Expose the runner log.

    /voyager/status deliberately drops log_tail: that payload is polled by the
    dashboard and the log is large. Dropping it there left the loop's own
    traceback unobservable from outside the Space -- the Hugging Face log
    stream can lag hours behind, so a wedged runner looked identical to a
    healthy one. This endpoint reads the same file the status builds from, on
    demand, without changing the status contract.
    """
    lines = max(1, min(int(lines or 200), 5000))
    tail = VOYAGER.tail(lines)
    if find:
        kept = [row for row in tail.splitlines() if find in row]
        tail = "\n".join(kept)
    return {
        "lines": lines,
        "find": find,
        "bytes": len(tail),
        "log": tail,
    }


@api.get("/voyager/probe")
def voyager_probe(seconds: int = 6):
    """Is the runner child alive, and is it using CPU or blocked?

    A wedged runner and a slow one look identical from the outside: both keep
    the process alive and write nothing. Sampling utime/stime across a short
    interval separates them. A process whose CPU counters do not move at all
    over several seconds while producing no output is blocked on a syscall, not
    thinking -- which is the distinction that took three rounds to establish by
    reading logs alone.
    """
    seconds = max(1, min(int(seconds or 6), 30))
    snapshot = VOYAGER.probe_runner(seconds)
    return snapshot


@api.get("/gemini-bot/status")
def gemini_bot_status():
    return GEMINI_BOT.status()


@api.get("/opencode-bot/status")
@api.get("/altoclef/status")
def altoclef_status():
    return ALTOCLEF.status()


@api.get("/opencode-bot/log")
def altoclef_log(lines: int = 100):
    return {"log": ALTOCLEF.tail(lines)}

@api.post("/v1/chat/completions")
async def chat(request: Request, authorization: str | None = Header(default=None)):
    _auth(authorization)
    try:
        payload = await request.json()
    except Exception as exc:
        raise HTTPException(status_code=400, detail=f"Invalid JSON: {str(exc)[:160]}") from exc
    if not isinstance(payload.get("messages"), list):
        raise HTTPException(status_code=400, detail="messages must be a list")
    requested_model = str(payload.get("model") or MODEL_ALIAS)
    if requested_model not in (MODEL_ALIASES | AUX_MODEL_ALIASES):
        raise HTTPException(status_code=404, detail=f"Unknown model: {requested_model}")
    if payload.get("stream"):
        return StreamingResponse(
            _stream(payload),
            media_type="text/event-stream",
            headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
        )
    try:
        return JSONResponse(await run_in_threadpool(_nonstream, payload))
    except RuntimeError as exc:
        raise HTTPException(status_code=503, detail=str(exc)[:800]) from exc

with gr.Blocks(title="Programa Voyager LLM Endpoint") as demo:
    gr.Markdown(
        "# Programa — Voyager LLM Endpoint\n"
        "**Granite-4.0-H-Tiny Q4_K_M**, 7B total / ~1B ativo, contexto 8K, 16 threads de decode / 16 de prefill, inferencia 100% CPU."
    )
    output = gr.JSON(label="IA / CPU")
    gr.Button("Status IA / CPU").click(status, outputs=output)
    gr.Markdown("## Minecraft + Xbox Friends")
    mc_output = gr.JSON(label="Minecraft")
    with gr.Row():
        gr.Button("Status Minecraft").click(STACK.status, outputs=mc_output)
        gr.Button("Iniciar Minecraft", variant="primary").click(STACK.start_async, outputs=mc_output)
        gr.Button("Parar Minecraft").click(STACK.stop, outputs=mc_output)
    gr.Markdown("## Voyager (alternativa manual)\nO Voyager continua disponível se você quiser ligar manualmente; o Naldo usa o AltoClef.")
    voyager_output = gr.JSON(label="Voyager")
    with gr.Row():
        gr.Button("Status Voyager").click(VOYAGER.status, outputs=voyager_output)
        gr.Button("Iniciar Voyager").click(VOYAGER.start_async, outputs=voyager_output)
        gr.Button("Parar Voyager").click(VOYAGER.stop, outputs=voyager_output)

    gr.Markdown("## Naldo — jogador autônomo\nO Naldo joga survival sozinho para zerar o jogo (mapa e inventário atual mantidos).")
    altoclef_output = gr.JSON(label="Naldo")
    with gr.Row():
        gr.Button("Status Naldo").click(ALTOCLEF.status, outputs=altoclef_output)
        gr.Button("Iniciar Naldo", variant="primary").click(ALTOCLEF.start_async, outputs=altoclef_output)
        gr.Button("Parar Naldo").click(ALTOCLEF.stop, outputs=altoclef_output)

if __name__ == "__main__":
    print(
        f"[boot] cpu-only model={MODEL_REPO}/{MODEL_FILE} threads={THREADS} "
        f"ctx={CTX} n_gpu_layers=0 mem_limit_gib={MEMORY_LIMIT_GIB}",
        flush=True,
    )
    # Keep the Gradio SDK app on a private port; the public OpenAI-compatible API
    # owns PORT=7860 exactly as in the previously stable deployment.
    demo.queue(default_concurrency_limit=1)
    demo.launch(
        server_name="0.0.0.0",
        server_port=GRADIO_PORT,
        prevent_thread_lock=True,
        ssr_mode=False,
        show_error=True,
    )
    uvicorn.run(api, host="0.0.0.0", port=PORT, log_level="info")