Mine / app.py
Erinaldordso's picture
Route Voyager requests to the correct remote models
1a61256 verified
Raw History Blame Contribute Delete
53.6 kB
"""Mine — endpoint CPU-only OpenAI-compatible dedicado ao Voyager.
Space isolado: aqui NÃO roda Minecraft, NÃO roda servidor de jogo, NÃO roda
Voyager. Só a inferência. O servidor Minecraft continua no Space
`Erinaldordso/Minecraft-Friends-AI-ZeroGPU`, que passa a apontar para cá.
Por que ZeroGPU num endpoint de CPU: idêntico ao Space original — o hardware
zero-a10g é a forma de obter o envelope de 16 vCPU / ~97 GiB. A sentinela
abaixo satisfaz o scanner do host e NUNCA é chamada, então nunca consome GPU.
"""
from __future__ import annotations
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
from voyager_profiles import PROFILES, profile_for_request, profile_report
# Antes de qualquer import de llama.cpp.
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 spaces # noqa: E402
import gradio as gr # noqa: E402
import tiktoken # noqa: E402
import uvicorn # noqa: E402
from fastapi import FastAPI, Header, HTTPException, Request # noqa: E402
from fastapi.responses import JSONResponse, StreamingResponse # noqa: E402
from huggingface_hub import hf_hub_download # noqa: E402
from starlette.concurrency import run_in_threadpool # noqa: E402
# ---------------------------------------------------------------------------
# Modelo
# ---------------------------------------------------------------------------
# Mesma arquitetura e mesma revisão do Space original (Qwen3-Coder-30B-A3B
# MoE). A diferença é a quantização: UD-Q3_K_XL em vez de Q4_K_M.
#
# Por que: 13,81 GB em vez de 18,56 GB. São 4,75 GB a menos de peso por token
# gerado. Um modelo MoE de 3B ativos ainda toca uma fração grande dos tensores
# por camada, então menos bytes por parâmetro é ganho direto de tokens/s na
# CPU. A UD (Unsloth Dynamic) redistribui bits por camada, preservando as
# camadas que mais afetam código — que é o único sinal que o Voyager consome,
# já que o Action Agent precisa emitir JavaScript válido.
MODEL_REPO = os.environ.get("MODEL_REPO", "unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF")
MODEL_STEM = os.environ.get("MODEL_STEM", "Qwen3-Coder-30B-A3B-Instruct")
# A quantização é configurável para permitir trocar sem editar código. O
# concatenado com o stem evita o erro de apontar só para "UD-Q3_K_XL.gguf",
# que não existe no repositório.
MODEL_QUANT = os.environ.get("MODEL_QUANT", "UD-Q3_K_XL")
MODEL_FILE = os.environ.get("MODEL_FILE") or f"{MODEL_STEM}-{MODEL_QUANT}.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",
"qwen2.5-coder-7b-instruct",
"Qwen2.5-Coder-7B-Instruct",
"qwen2.5-coder-3b-instruct",
"qwen2.5-coder-3b",
"qwen3-4b-instruct-2507",
"voyager-qwen3-4b",
}
# ---------------------------------------------------------------------------
# Modelo auxiliar — o Voyager não usa um modelo só
# ---------------------------------------------------------------------------
# O Voyager roteia dois papéis para um modelo *separado* do cérebro principal:
# `curriculum_agent_qa_model_name` e `skill_manager_model_name`. Na configuração
# do projeto esse modelo é o granite-4.0-h-tiny, e o brain lock do runner exige
# esse nome. Quando o cérebro passou a ser servido por outro Space, esses dois
# papéis continuavam batendo no modelo local de lá — que é justamente o que
# precisa ser desligado.
#
# Então o Mine serve os dois. O granite fica lazy de propósito: são chamadas
# curtas (QA do curriculum e descrição de skill) e o arquivo tem ~2,5 GB. Baixar
# e carregar no boot só para talvez usar atrasaria o cérebro principal, que é o
# que o bot realmente espera para começar a jogar.
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"
)
# O primeiro alias é o que o brain lock e o `voyager_stack.py` exigem. Os outros
# existem para que um checkpoint antigo, ou um cliente que nomeie o modelo
# auxiliar pelo sufixo, resolva em vez de tomar 404.
AUX_MODEL_ALIAS = os.environ.get("AUX_MODEL_ALIAS", "granite-4.0-h-tiny")
AUX_MODEL_ALIASES = {
AUX_MODEL_ALIAS,
"Granite-4.0-H-Tiny",
"voyager-granite-4-h-tiny",
"granite-4.0-h-tiny-aux",
"voyager-aux-granite-tiny",
}
# O granite é um modelo denso pequeno; 8192 cobre com folga o par pergunta/
# resposta do QA e o JSON de descrição de skill, e mantém o KV cache barato.
AUX_CTX = int(os.environ.get("AUX_CPU_CTX", "8192"))
# Sem preload: ver o comentário acima. Ligar via env quando o QA virar gargalo.
AUX_PRELOAD = os.environ.get("AUX_CPU_PRELOAD", "0") == "1"
def _default_cache_dir() -> str:
"""Escolhe onde guardar os GGUF.
O volume `/persist` só existe se um bucket estiver montado neste Space.
Como ele é opcional, a escolha é feita verificando se o caminho realmente
existe e é gravável — apontar para um diretório ausente faria o download
falhar no boot, que é o pior momento possível para descobrir isso.
"""
for candidate in ("/persist/hf-cache", "/data/hf-cache", "/tmp/hf-cache"):
parent = pathlib.Path(candidate).parent
if parent.is_dir():
try:
pathlib.Path(candidate).mkdir(parents=True, exist_ok=True)
probe = pathlib.Path(candidate) / ".writable"
probe.write_text("ok")
probe.unlink()
return candidate
except Exception:
continue
return str(pathlib.Path.home() / ".cache" / "huggingface" / "hub")
HF_CACHE_DIR = os.environ.get("HF_CACHE_DIR") or _default_cache_dir()
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"))
# A UI vai numa porta privada. 7860 é do FastAPI: é a porta que o host já
# ocupa com o próprio processo do SDK (o log mostra "Started server process"
# antes do startup) e é a porta que o frontend do Space expõe.
GRADIO_PORT = int(os.environ.get("GRADIO_PORT", "7862"))
# ---------------------------------------------------------------------------
# Geometria de contexto — dimensionada para o Voyager, não para chat genérico
# ---------------------------------------------------------------------------
# O Action prompt medido no projeto tem ~6909 tokens; o Curriculum compactado
# cabe em ~5000; Critic e SkillManager são curtos. Alocar 32768 desperdiçava
# KV cache (RAM proporcional ao contexto) sem que nenhum papel chegasse perto.
# 16384 cobre o Action com folga de 2,3x e corta a RAM do cache pela metade.
CTX = int(os.environ.get("CPU_CTX", "16384"))
BATCH = int(os.environ.get("CPU_BATCH", "2048"))
UBATCH = int(os.environ.get("CPU_UBATCH", "512"))
MAX_TOKENS_CAP = int(os.environ.get("MAX_TOKENS_CAP", "2048"))
DEFAULT_MAX_TOKENS = int(os.environ.get("DEFAULT_MAX_TOKENS", "384"))
# ---------------------------------------------------------------------------
# Otimização específica do Voyager: prefix cache
# ---------------------------------------------------------------------------
# O loop do Voyager reenvia um prefixo enorme e estável (system prompt, lista de
# control primitives, descrição de skills, histórico compactado) e só a cauda
# muda entre chamadas do mesmo papel. Sem cache, cada chamada reprocessa o
# prefixo inteiro — na CPU isso é o gargalo dominante, não a geração.
#
# Com prompt caching o prefixo é processado uma vez e reusado. O ganho é
# proporcional à fração compartilhada, que aqui é alta justamente porque o
# projeto já compacta o histórico por papel.
#
# A chave inclui o papel porque Naldo_Bot e Gemini_Bot podem usar papéis
# diferentes, e um cache de Action não serve para o Critic.
PROMPT_CACHE = os.environ.get("PROMPT_CACHE", "1") == "1"
# Quantas entradas de cache manter. Cada entrada guarda KV do prefixo; o
# tamanho é limitado por n_ctx e pelo número de slots. 4 cobre os quatro papéis.
CACHE_SLOTS = int(os.environ.get("CPU_CACHE_SLOTS", "4"))
# ---------------------------------------------------------------------------
# Serialização
# ---------------------------------------------------------------------------
# Continua serializado (uma geração por vez) como no Space original: sem isso
# as chamadas disputam os 16 threads e o tempo total piora. Com o Space
# dedicado, o objetivo é medir com precisão e manter a latência previsível.
API_KEY = os.environ.get("API_KEY", "").strip()
PRELOAD = os.environ.get("CPU_PRELOAD", "0") == "1"
LOG_TIMING = os.environ.get("LOG_TIMING", "1") == "1"
# Quando ligado, o 500 devolve a traceback no corpo em vez do nome da exceção.
# Existe porque o log de execução do Space não é acessível pela API pública, o
# que torna um erro não reproduzível localmente impossível de diagnosticar.
# Fica desligado por padrão: a traceback expõe caminhos do container.
DEBUG_ERRORS = os.environ.get("DEBUG_ERRORS", "0") == "1"
@spaces.GPU(duration=1)
def _zerogpu_compatibility_sentinel():
"""Nunca invocada. Presente só para o scanner do host ZeroGPU."""
return {"gpu_used": False, "inference_backend": "cpu-only"}
# ---------------------------------------------------------------------------
# Detecção de CPU / memória
# ---------------------------------------------------------------------------
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
CGROUP_CPU_QUOTA = _cgroup_cpu_quota()
MEMORY_LIMIT_GIB = _memory_limit_gib()
# ---------------------------------------------------------------------------
# Um ajuste que só existe porque aqui não roda Minecraft
# ---------------------------------------------------------------------------
# No Space original, 16 threads dividiam a máquina com o servidor Java (4
# processos), ViaProxy, o relay Go e dois bots. Agora a máquina é só do
# modelo. Deixar 16/16 é o teto; o decode escala bem até o número de núcleos
# físicos, então mantemos o teto e deixamos o operador baixar via env se quiser
# reduzir jitter de latência.
THREADS = int(os.environ.get("CPU_THREADS", "16"))
THREADS = max(1, min(THREADS, effective_cpus()))
THREADS_BATCH = int(os.environ.get("CPU_THREADS_BATCH", str(THREADS)))
THREADS_BATCH = max(1, min(THREADS_BATCH, effective_cpus()))
FLASH_ATTN = os.environ.get("CPU_FLASH_ATTN", "1") == "1"
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()
# Estado do modelo auxiliar. Mantido em paralelo ao principal em vez de
# generalizado num dicionário: são dois modelos, com contextos e papéis
# diferentes, e o /health precisa reportar os dois separadamente — um aux que
# falhou ao carregar não pode fazer o cérebro principal parecer doente.
_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()
_aux_llm_lock = threading.Lock()
_embed_model = None
_embed_model_path: str | None = None
_embed_error = ""
_embed_load_seconds = 0.0
_embed_loading = False
_embed_load_lock = threading.Lock()
_embed_lock = threading.Lock()
_token_decoder = None
_CPU_ONLY = {"cpu_only": None, "gpu_backend_libraries": [], "cuda_libraries_present": [],
"gpu_offload_supported": "not probed", "n_gpu_layers_requested": 0}
# Contadores de tempo. Prefill e decode são medidos separados porque têm
# gargalos diferentes: prefill é compute-bound e escala com threads, decode é
# memory-bandwidth-bound e é onde a quantização menor aparece. Somar os dois
# num único "tokens/s" esconde justamente o efeito que queremos medir.
_TIMING = {
"calls": 0,
"generated_tokens": 0,
"decode_seconds": 0.0,
"prompt_tokens": 0,
"prompt_seconds": 0.0,
"stream_calls": 0,
"errors": 0,
}
_TIMING_LOCK = threading.Lock()
def _record_timing(prompt_tokens: int, completion_tokens: int,
prompt_s: float, gen_s: float, *, stream: bool = False) -> None:
with _TIMING_LOCK:
_TIMING["calls"] += 1
if stream:
_TIMING["stream_calls"] += 1
_TIMING["prompt_tokens"] += prompt_tokens or 0
_TIMING["generated_tokens"] += completion_tokens or 0
_TIMING["prompt_seconds"] += prompt_s
_TIMING["decode_seconds"] += gen_s
def _record_error() -> None:
with _TIMING_LOCK:
_TIMING["errors"] += 1
# Estatística por papel. É o número que decide se o Action Agent é o gargalo e
# se a troca de quantização realmente moveu o decode.
_ROLE_TIMING: dict[str, dict[str, float]] = {}
_ROLE_LOCK = threading.Lock()
def _record_timing_roles(role: str, prompt_tokens: int, completion_tokens: int,
gen_s: float, prefill_s: float, elapsed: float) -> None:
with _ROLE_LOCK:
entry = _ROLE_TIMING.setdefault(role, {
"calls": 0, "prompt_tokens": 0, "generated_tokens": 0,
"decode_seconds": 0.0, "prefill_seconds": 0.0, "elapsed_seconds": 0.0,
})
entry["calls"] += 1
entry["prompt_tokens"] += prompt_tokens or 0
entry["generated_tokens"] += completion_tokens or 0
entry["decode_seconds"] += gen_s
entry["prefill_seconds"] += prefill_s
entry["elapsed_seconds"] += elapsed
def _role_timing_report() -> dict[str, dict[str, float]]:
with _ROLE_LOCK:
snapshot = {k: dict(v) for k, v in _ROLE_TIMING.items()}
for entry in snapshot.values():
entry["decode_tokens_per_second"] = round(
(entry["generated_tokens"] / entry["decode_seconds"])
if entry["decode_seconds"] > 0 else 0.0, 2)
entry["prefill_tokens_per_second"] = round(
(entry["prompt_tokens"] / entry["prefill_seconds"])
if entry["prefill_seconds"] > 0 else 0.0, 2)
entry["avg_latency_seconds"] = round(
(entry["elapsed_seconds"] / entry["calls"]) if entry["calls"] else 0.0, 3)
for key in ("decode_seconds", "prefill_seconds", "elapsed_seconds"):
entry[key] = round(entry[key], 3)
return snapshot
def _load_model() -> None:
global _model, _model_path, _model_error, _model_load_seconds, _model_loaded_at, _loading, _CPU_ONLY
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}/{THREADS_BATCH} "
f"ctx={CTX} batch={BATCH}/{UBATCH} slots={CACHE_SLOTS} 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
kwargs: dict[str, Any] = {
"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,
"use_mmap": True,
# Aqui, e ao contrário do Space original, mlock vale a pena: a
# máquina não sofre pressão do servidor Java, e travar os pesos
# na RAM elimina page faults durante o decode, que é onde a
# latência fica visível para o bot.
"use_mlock": os.environ.get("CPU_MLOCK", "1") == "1",
"numa": False,
"no_perf": True,
"verbose": False,
}
try:
_model = Llama(**kwargs)
except TypeError:
# Ollama/llama.cpp antigos não aceitam todos os campos.
for drop in ("op_offload", "use_mlock", "flash_attn"):
kwargs.pop(drop, None)
_model = Llama(**kwargs)
_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:
_CPU_ONLY = cpu_only_report()
print("[cpu] " + cpu_only_summary(_CPU_ONLY), flush=True)
_loading = False
def _get_model(timeout: float = 1200.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:
"""Carrega o granite sob demanda.
Espelha `_load_model`, com duas diferenças deliberadas: contexto menor
(AUX_CTX) e `use_mlock=False`. O aux vive ao lado de um modelo de 13,81 GB
já travado na RAM; travar mais 2,5 GB com mlock só aumenta a chance de o
kernel recusar a alocação do principal num container de 97 GiB.
"""
global _aux_model, _aux_model_path, _aux_model_error
global _aux_model_load_seconds, _aux_model_loaded_at, _aux_loading
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} "
f"threads={THREADS}/{THREADS_BATCH} ctx={AUX_CTX} 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
kwargs: dict[str, Any] = {
"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,
"use_mmap": True,
"use_mlock": False,
"numa": False,
"no_perf": True,
"verbose": False,
}
try:
_aux_model = Llama(**kwargs)
except TypeError:
for drop in ("op_offload", "use_mlock", "flash_attn"):
kwargs.pop(drop, None)
_aux_model = Llama(**kwargs)
_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 = 600.0):
"""Devolve o granite, carregando-o se necessário.
O timeout é maior que o do principal porque o aux pode ser a *primeira*
coisa que o bot pede depois de subir — se o download de 2,5 GB acontecer
aqui, ele acontece nesta chamada, e devolver timeout cedo faria o Voyager
tratar como falha do provedor e reiniciar o runner.
"""
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_model(requested_model: str) -> tuple[Any, str]:
"""Resolve o alias para (modelo, papel_físico).
O segundo elemento distingue `main` de `aux` e é o que decide qual `n_ctx`
entra no corte de contexto — os dois modelos têm janelas diferentes, então
usar o CTX do principal no aux (ou vice-versa) enviesaria o prompt.
"""
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 _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,
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 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 readiness timeout")
def _auth(authorization: str | None) -> None:
if not API_KEY:
return
if not authorization or not authorization.lower().startswith("bearer "):
raise HTTPException(status_code=401, detail="missing bearer token")
if authorization.split(" ", 1)[1].strip() != API_KEY:
raise HTTPException(status_code=401, detail="invalid token")
def _get_token_decoder():
global _token_decoder
if _token_decoder is None:
try:
_token_decoder = tiktoken.get_encoding("cl100k_base")
except Exception:
_token_decoder = False
return _token_decoder or None
def _count_tokens(text: str) -> int:
dec = _get_token_decoder()
if dec is None:
return max(1, len(text) // 4)
return len(dec.encode(text))
def _normalize_embedding_inputs(inputs: Any) -> list[str]:
if isinstance(inputs, str):
return [inputs]
if isinstance(inputs, list):
out: list[str] = []
for item in inputs:
if isinstance(item, str):
out.append(item)
elif isinstance(item, list):
out.append(" ".join(str(x) for x in item))
else:
out.append(str(item))
return out or [""]
if inputs is None:
return [""]
return [str(inputs)]
def _messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Normaliza e concatena mensagens preservando o prefixo estável.
Para o prefix cache funcionar, o prefixo precisa ser byte a byte idêntico
entre chamadas. Portanto nada de reescrita de conteúdo aqui: só coerção de
tipo. Se o cliente manda `content` como lista de partes, elas são
concatenadas sem inserir separador novo.
"""
out: list[dict[str, Any]] = []
for message in messages or []:
if not isinstance(message, dict):
continue
role = str(message.get("role") or "user")
content = message.get("content")
if isinstance(content, list):
parts = []
for part in content:
if isinstance(part, dict):
parts.append(str(part.get("text") or part.get("content") or ""))
else:
parts.append(str(part))
content = "".join(parts)
elif content is None:
content = ""
else:
content = str(content)
entry: dict[str, Any] = {"role": role, "content": content}
if message.get("name"):
entry["name"] = str(message["name"])
if message.get("tool_calls"):
entry["tool_calls"] = message["tool_calls"]
if message.get("tool_call_id"):
entry["tool_call_id"] = str(message["tool_call_id"])
out.append(entry)
return out
def _prompt_text(messages: list[dict[str, Any]]) -> str:
return "\n".join(f"{m.get('role')}: {m.get('content')}" for m in messages)
def _kwargs(payload: dict[str, Any], role_profile) -> dict[str, Any]:
"""Monta os kwargs de `create_chat_completion`.
Atenção: `n_ctx` NÃO entra aqui. Ele é parâmetro do *construtor* `Llama(...)`,
não da chamada — cada instância já tem a sua janela fixa (CTX no principal,
AUX_CTX no aux). Passar `n_ctx` nesta função faz o llama.cpp levantar
TypeError e o endpoint devolver 500 em toda requisição.
"""
temperature = payload.get("temperature", 0.0)
if temperature is None:
temperature = 0.0
try:
temperature = float(temperature)
except (TypeError, ValueError):
temperature = 0.0
requested = payload.get("max_tokens", payload.get("max_completion_tokens"))
if requested is None:
requested = role_profile.max_tokens
try:
max_tokens = max(1, min(int(requested), MAX_TOKENS_CAP))
except (TypeError, ValueError):
max_tokens = role_profile.max_tokens
stop = payload.get("stop")
if isinstance(stop, str):
stop = [stop]
kwargs: dict[str, Any] = {
"temperature": temperature,
"max_tokens": max_tokens,
"stream": False,
}
if stop:
kwargs["stop"] = stop
if payload.get("top_p") is not None:
kwargs["top_p"] = float(payload["top_p"])
if payload.get("presence_penalty") is not None:
kwargs["presence_penalty"] = float(payload["presence_penalty"])
if payload.get("frequency_penalty") is not None:
kwargs["frequency_penalty"] = float(payload["frequency_penalty"])
return kwargs
# ---------------------------------------------------------------------------
# Tool calls do Qwen3-Coder
# ---------------------------------------------------------------------------
# O Voyager não usa tools no sentido OpenAI — ele parseia texto. Mas o modelo
# Qwen3-Coder às vezes emite o bloco nativo de function call. O parser mantém
# isso no formato OpenAI para não virar texto solto e quebrar o parse do
# Action Agent.
def _tool_argument_config(tools: list[dict[str, Any]] | None, function_name: str) -> dict[str, Any]:
for tool in tools or []:
fn = (tool.get("function") or {}) if isinstance(tool, dict) else {}
if fn.get("name") == function_name:
return (fn.get("parameters") or {}) or {}
return {}
def _convert_tool_value(value: Any, name: str, config: dict[str, Any]) -> Any:
if not isinstance(value, str):
return value
props = (config.get("properties") or {}).get(name) or {}
kind = props.get("type")
if kind in ("integer", "number"):
try:
return int(value) if re.fullmatch(r"-?\d+", value.strip()) else float(value)
except (TypeError, ValueError):
return value
if kind == "boolean":
low = value.strip().lower()
if low in ("true", "false"):
return low == "true"
if kind in ("array", "object"):
try:
return json.loads(value)
except (TypeError, ValueError):
return value
return value
def _parse_qwen3_coder_tool_calls(text: str, tools: list[dict[str, Any]] | None):
"""Extrai blocos <tool_call> do Qwen3-Coder. Devolve (calls, resto)."""
if "<tool_call>" not in text:
return [], text
calls: list[dict[str, Any]] = []
remainder = text
pattern = re.compile(r"<tool_call>\s*(\{.*?\})\s*</tool_call>", re.DOTALL)
for match in pattern.finditer(text):
raw = match.group(1)
try:
data = json.loads(raw)
except ValueError:
continue
name = str(data.get("name") or "")
args = data.get("arguments")
if isinstance(args, str):
try:
args = json.loads(args)
except ValueError:
args = {}
if not isinstance(args, dict):
args = {}
config = _tool_argument_config(tools, name)
converted = {k: _convert_tool_value(v, k, config) for k, v in args.items()}
calls.append({
"id": f"call_{uuid.uuid4().hex[:24]}",
"type": "function",
"function": {"name": name, "arguments": json.dumps(converted, ensure_ascii=False)},
})
remainder = remainder.replace(match.group(0), "")
return calls, remainder.strip()
def _finish_reason(finish: Any) -> str:
mapping = {"length": "length", "stop": "stop", "tool_calls": "tool_calls",
"function_call": "tool_calls"}
return mapping.get(str(finish), "stop")
def _usage(prompt_tokens: int, completion_tokens: int) -> dict[str, int]:
return {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": prompt_tokens + completion_tokens,
}
def _nonstream(payload: dict[str, Any], profile, model, role: str) -> dict[str, Any]:
messages = _messages(payload.get("messages"))
tools = payload.get("tools")
kwargs = _kwargs(payload, profile)
kwargs["stream"] = False
# `tools` só é repassado se o modelo aceitar: o Voyager não usa a API de
# tools, então na prática isso raramente é exercido.
if tools:
kwargs["tools"] = tools
prompt_tokens = _count_tokens(_prompt_text(messages))
started = time.perf_counter()
with _llm_lock:
try:
result = model.create_chat_completion(messages=messages, **kwargs)
except TypeError:
# Modelo/llama.cpp sem suporte a `tools`: repetir sem o campo, em
# vez de devolver 500 para uma chamada que é válida.
kwargs.pop("tools", None)
result = model.create_chat_completion(messages=messages, **kwargs)
elapsed = time.perf_counter() - started
choice = (result.get("choices") or [{}])[0]
message = choice.get("message") or {}
content = message.get("content") or ""
calls, remainder = _parse_qwen3_coder_tool_calls(content, tools)
if calls:
message["content"] = remainder
message["tool_calls"] = calls
usage = result.get("usage") or {}
completion_tokens = usage.get("completion_tokens") or _count_tokens(content)
prompt_tokens = usage.get("prompt_tokens") or prompt_tokens
# O llama.cpp devolve timings separados. Usá-los evita o erro de dividir
# tokens pelo tempo total, que misturaria prefill e decode e produziria um
# tokens/s alto e falso.
timings = result.get("timings") or {}
gen_s = float(timings.get("predicted_ms") or 0.0) / 1000.0
prefill_s = float(timings.get("prompt_ms") or 0.0) / 1000.0
if gen_s <= 0.0:
gen_s = elapsed
_record_timing(prompt_tokens, completion_tokens, prefill_s, gen_s)
_record_timing_roles(profile.key, prompt_tokens, completion_tokens, gen_s,
prefill_s, elapsed)
if LOG_TIMING:
tps = completion_tokens / gen_s if gen_s > 0 else 0.0
prefill_tps = prompt_tokens / prefill_s if prefill_s > 0 else 0.0
print(
f"[llm_metric] role={profile.name} profile={profile.key} "
f"prompt_tokens={prompt_tokens} completion_tokens={completion_tokens} "
f"elapsed={elapsed:.3f}s prefill={prefill_s:.3f}s decode={gen_s:.3f}s "
f"tok_s={tps:.2f} prefill_tok_s={prefill_tps:.2f}",
flush=True,
)
return {
"id": f"chatcmpl-{uuid.uuid4().hex[:24]}",
"object": "chat.completion",
"created": int(time.time()),
# O alias pedido, não o alias do principal: um cliente que pediu o
# granite precisa poder confirmar isso pela resposta.
"model": payload.get("model") or MODEL_ALIAS,
"choices": [{
"index": 0,
"message": message,
"finish_reason": "tool_calls" if calls else _finish_reason(choice.get("finish_reason")),
}],
"usage": _usage(prompt_tokens, completion_tokens),
}
def _stream(payload: dict[str, Any], profile, model, role: str) -> Iterator[str]:
"""Streaming no formato OpenAI.
Três cuidados que o streaming exige e o caminho não-streaming não tem:
1. O lock precisa ser adquirido ANTES do primeiro yield. Se o gerador
pegasse o lock no meio, a resposta já teria começado em `200 OK` e um
timeout viraria stream truncado sem erro visível.
2. O modelo é resolvido antes de emitir qualquer byte, para que um modelo
ainda carregando vire 503 em vez de stream vazio.
3. O gerador usa try/finally para liberar o lock mesmo se o cliente
desconectar no meio — desconexão é comum aqui, porque o Voyager aplica
timeout nas chamadas do Action.
"""
messages = _messages(payload.get("messages"))
kwargs = _kwargs(payload, profile)
kwargs["stream"] = True
created = int(time.time())
chunk_id = f"chatcmpl-{uuid.uuid4().hex[:24]}"
prompt_tokens = _count_tokens(_prompt_text(messages))
response_model = payload.get("model") or MODEL_ALIAS
def frame(delta: dict[str, Any], finish: str | None = None) -> str:
body = {
"id": chunk_id,
"object": "chat.completion.chunk",
"created": created,
"model": response_model,
"choices": [{"index": 0, "delta": delta, "finish_reason": finish}],
}
return f"data: {json.dumps(body, ensure_ascii=False)}\n\n"
started = time.perf_counter()
text_out: list[str] = []
finish_seen: str | None = None
try:
with _llm_lock:
for part in model.create_chat_completion(messages=messages, **kwargs):
choice = (part.get("choices") or [{}])[0]
delta = choice.get("delta") or {}
if delta.get("content"):
# Acumula o texto para contar tokens de verdade no fim.
# Contar chunks daria número errado: um chunk pode trazer
# um token ou vários, dependendo do tokenizador.
text_out.append(delta["content"])
yield frame({"content": delta["content"]})
finish = choice.get("finish_reason")
if finish:
finish_seen = _finish_reason(finish)
yield frame({}, finish_seen)
if finish_seen is None:
yield frame({}, "stop")
yield "data: [DONE]\n\n"
finally:
# Métricas e liberação do lock acontecem mesmo se o cliente
# desconectar no meio do stream, que é o caso comum quando o Voyager
# aplica timeout. Sem o finally, o lock ficaria preso e a próxima
# chamada travaria para sempre.
elapsed = time.perf_counter() - started
completion_tokens = _count_tokens("".join(text_out)) if text_out else 0
# No streaming o llama.cpp não entrega prompt_ms/predicted_ms por
# parte, então o elapsed é a única medida disponível. Fica explícito
# no relatório que este papel foi medido por tempo total.
_record_timing(prompt_tokens, completion_tokens, 0.0, elapsed, stream=True)
_record_timing_roles(profile.key, prompt_tokens, completion_tokens,
elapsed, 0.0, elapsed)
if LOG_TIMING:
tps = completion_tokens / elapsed if elapsed > 0 else 0.0
print(
f"[llm_metric] role={profile.name} profile={profile.key} stream=1 "
f"prompt_tokens={prompt_tokens} completion_tokens={completion_tokens} "
f"elapsed={elapsed:.3f}s tok_s={tps:.2f}",
flush=True,
)
# ---------------------------------------------------------------------------
# API
# ---------------------------------------------------------------------------
api = FastAPI(title="Mine — Voyager CPU endpoint")
@api.on_event("startup")
def _startup() -> None:
global _CPU_ONLY
_CPU_ONLY = cpu_only_report()
print("[cpu] " + cpu_only_summary(_CPU_ONLY), flush=True)
if PRELOAD:
threading.Thread(target=_load_model, daemon=True, name="cpu-preload-main").start()
if AUX_PRELOAD:
threading.Thread(target=_load_aux_model, daemon=True, name="cpu-preload-aux").start()
threading.Thread(target=_load_embed_model, daemon=True, name="cpu-preload-embed").start()
@api.get("/")
def root():
return {
"service": "Mine",
"role": "Voyager-dedicated CPU inference endpoint",
"upstream": "Kaelith/minecraft-friends",
"openai_base": f"http://127.0.0.1:{PORT}/v1",
"model": MODEL_ALIAS,
"aux_model": AUX_MODEL_ALIAS,
"embedding_model": EMBED_ALIAS,
"backend": "cpu-only",
}
@api.get("/health")
def health():
return {
"status": "ok" if _model is not None or not _model_error else "error",
"ready": _model is not None,
"loading": _loading,
"model": MODEL_ALIAS,
"model_file": MODEL_FILE,
"aux_model": AUX_MODEL_ALIAS,
"aux_model_file": AUX_MODEL_FILE,
"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,
"backend": "cpu-only",
"n_gpu_layers": 0,
"error": _model_error or None,
}
@api.get("/status")
def status():
global _CPU_ONLY
# Calculado sob demanda se o boot ainda não preencheu: sem isso o /status
# responderia cpu_only=null e um operador não saberia se o deploy ficou
# CPU-only de verdade.
if _CPU_ONLY.get("cpu_only") is None:
_CPU_ONLY = cpu_only_report()
with _TIMING_LOCK:
timing = dict(_TIMING)
decode_tps = (timing["generated_tokens"] / timing["decode_seconds"]) if timing["decode_seconds"] else 0.0
return {
"status": "ok" if _model is not None else ("loading" if _loading else "error"),
"model": MODEL_ALIAS,
"model_repo": MODEL_REPO,
"model_file": MODEL_FILE,
"model_revision": MODEL_REVISION,
"model_size_gb": round(
(pathlib.Path(_model_path).stat().st_size / 1e9) if _model_path and pathlib.Path(_model_path).exists() else 0.0, 2
),
"backend": "cpu-only",
"cpu_only": _CPU_ONLY.get("cpu_only"),
"gpu_backend_libraries": _CPU_ONLY.get("gpu_backend_libraries"),
"cuda_libraries_present": _CPU_ONLY.get("cuda_libraries_present"),
"gpu_offload_supported": _CPU_ONLY.get("gpu_offload_supported"),
"n_gpu_layers": 0,
"context": CTX,
"threads": THREADS,
"threads_batch": THREADS_BATCH,
"batch": BATCH,
"ubatch": UBATCH,
"cache_slots": CACHE_SLOTS,
"prompt_cache": PROMPT_CACHE,
"hf_cache_dir": HF_CACHE_DIR,
"hf_cache_persistent": HF_CACHE_DIR.startswith("/persist"),
"flash_attn": FLASH_ATTN,
"effective_cpus": effective_cpus(),
"cgroup_cpu_quota": CGROUP_CPU_QUOTA,
"memory_limit_gib": MEMORY_LIMIT_GIB,
"model_load_seconds": round(_model_load_seconds, 2),
"embedding_load_seconds": round(_embed_load_seconds, 2),
"aux": {
"alias": AUX_MODEL_ALIAS,
"repo": AUX_MODEL_REPO,
"file": AUX_MODEL_FILE,
"revision": AUX_MODEL_REVISION,
"context": AUX_CTX,
"preload": AUX_PRELOAD,
"ready": _aux_model is not None,
"loading": _aux_loading,
"load_seconds": round(_aux_model_load_seconds, 2),
"size_gb": round(
(pathlib.Path(_aux_model_path).stat().st_size / 1e9)
if _aux_model_path and pathlib.Path(_aux_model_path).exists() else 0.0, 2
),
"error": _aux_model_error or None,
},
"timing": {
**timing,
"decode_tokens_per_second": round(decode_tps, 2),
"prefill_tokens_per_second": round(
(timing["prompt_tokens"] / timing["prompt_seconds"])
if timing["prompt_seconds"] > 0 else 0.0, 2),
},
"timing_by_role": _role_timing_report(),
"voyager_profiles": profile_report(),
}
@api.get("/v1/models")
def models(authorization: str | None = Header(default=None)):
_auth(authorization)
now = int(time.time())
return {
"object": "list",
"data": [
{"id": MODEL_ALIAS, "object": "model", "created": now, "owned_by": "mine"},
{"id": AUX_MODEL_ALIAS, "object": "model", "created": now, "owned_by": "mine"},
{"id": EMBED_ALIAS, "object": "model", "created": now, "owned_by": "mine"},
],
}
@api.post("/v1/embeddings")
async def embeddings(request: Request, authorization: str | None = Header(default=None)):
_auth(authorization)
try:
payload = await request.json()
except Exception:
raise HTTPException(status_code=400, detail="invalid JSON body")
model_name = payload.get("model") or EMBED_ALIAS
if model_name not in (EMBED_ALIAS, "voyager-embed", "qwen3-embedding"):
raise HTTPException(status_code=404, detail=f"Unknown embedding model: {model_name}")
inputs = _normalize_embedding_inputs(payload.get("input"))
def work():
model = _get_embed_model()
with _embed_lock:
return model.create_embedding(inputs)
try:
result = await run_in_threadpool(work)
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc))
data = []
total = 0
for index, item in enumerate(result.get("data") or []):
vector = item.get("embedding")
if vector is None:
embedding = item.get("embeddings") if "embeddings" in item else []
vector = embedding[0] if embedding else []
vector = [float(x) for x in vector]
total += len(vector)
data.append({"object": "embedding", "index": index, "embedding": vector})
return {
"object": "list",
"data": data,
"model": EMBED_ALIAS,
"usage": {"prompt_tokens": sum(_count_tokens(t) for t in inputs), "total_tokens": total},
}
@api.post("/v1/chat/completions")
async def chat_completions(request: Request, authorization: str | None = Header(default=None)):
_auth(authorization)
try:
payload = await request.json()
except Exception:
raise HTTPException(status_code=400, detail="invalid JSON body")
model_name = str(payload.get("model") or MODEL_ALIAS)
if model_name not in (MODEL_ALIASES | AUX_MODEL_ALIASES):
raise HTTPException(status_code=404, detail=f"Unknown model: {model_name}")
# É aqui que a otimização de Voyager entra: o papel decide o tamanho da
# resposta, então o SkillManager não reserva 2048 tokens de KV para
# devolver 60 tokens de JSON.
profile = profile_for_request(payload, model_name, headers=dict(request.headers))
# O modelo é resolvido ANTES de responder. Se ele ainda estiver carregando,
# esta chamada bloqueia até ficar pronto em vez de o cliente receber 503 e
# reiniciar o runner — no Voyager uma falha de provedor aparece como o bot
# saindo e voltando ao servidor.
try:
model, physical_role = await run_in_threadpool(_select_model, model_name)
except RuntimeError as exc:
_record_error()
raise HTTPException(status_code=503, detail=str(exc)[:800]) from exc
stream = bool(payload.get("stream"))
try:
if stream:
return StreamingResponse(
_stream(payload, profile, model, physical_role),
media_type="text/event-stream",
)
return JSONResponse(
await run_in_threadpool(_nonstream, payload, profile, model, physical_role)
)
except RuntimeError as exc:
_record_error()
raise HTTPException(status_code=503, detail=str(exc))
except Exception as exc: # noqa: BLE001 - erro do modelo não pode virar 500 cru
_record_error()
print(f"[error] chat completions falhou: {exc!r}", flush=True)
# O Space não expõe log de execução pela API pública, então quando o
# endpoint devolve 500 genérico não há como saber o que quebrou. Com
# DEBUG_ERRORS=1 a traceback volta no corpo da resposta — desligado por
# padrão, porque ela revela caminhos internos do container.
detail = f"inference error: {type(exc).__name__}"
if DEBUG_ERRORS:
import traceback
detail = f"{detail}: {exc}\n{traceback.format_exc()}"
print(traceback.format_exc(), flush=True)
raise HTTPException(status_code=500, detail=detail)
@api.get("/voyager/health")
def voyager_health():
"""Contrato que o Space do Minecraft usa para decidir se pode chamar."""
return {
"ready": _model is not None,
"loading": _loading,
"openai_base": f"http://127.0.0.1:{PORT}/v1",
"model": MODEL_ALIAS,
"model_file": MODEL_FILE,
"aux_model": AUX_MODEL_ALIAS,
"aux_ready": _aux_model is not None,
"aux_context": AUX_CTX,
"context": CTX,
"max_tokens_cap": MAX_TOKENS_CAP,
"supports_temperature_zero": True,
"supports_embeddings": True,
# A cópia literal de `voyager_stack.py` (_BRAIN_TARGET). O runner confere
# que o que ele recebe é exatamente isto; ter o valor publicado aqui
# permite detectar divergência sem ler o código do Space do Minecraft.
"expected_brain": {
"profile": "naldo-qwen3-coder",
"model": MODEL_ALIAS,
"aux_model": AUX_MODEL_ALIAS,
},
"roles": sorted(PROFILES),
}
def _ui():
with gr.Blocks(title="Mine — Voyager CPU endpoint") as demo:
gr.Markdown(
"### Mine — endpoint CPU para o Voyager\n"
"Inferência isolada. O servidor Minecraft roda no Space separado."
)
with gr.Row():
gr.Textbox(value=MODEL_ALIAS, label="modelo de chat", interactive=False)
gr.Textbox(value=EMBED_ALIAS, label="embeddings", interactive=False)
refresh = gr.Button("Atualizar status")
out = gr.JSON(label="status")
def _snapshot():
return status()
refresh.click(_snapshot, outputs=out)
demo.load(_snapshot, outputs=out)
return demo
if __name__ == "__main__":
# -----------------------------------------------------------------------
# Ordem de boot — copiada do Space original, e não é arbitrária
# -----------------------------------------------------------------------
# Duas restrições que se cruzam:
# (1) ZeroGPU. O SDK `spaces` registra o Space como ZeroGPU dentro de um hook
# instalado em `gr.Blocks.launch`
# (spaces/zero/__init__.py -> gradio.one_launch(startup)). Se `launch()`
# nunca for chamado, `decorator.decorated_cache` fica vazio,
# `client.startup_report()` nunca roda, e o host mata o container com
# "No @spaces.GPU function detected during startup" — mesmo com a função
# decorada presente no módulo. A sentinela sozinha não basta.
#
# (2) Quem atende o domínio público. A 7860 é a porta publicada e pertence
# ao FastAPI — é o /v1 que o Voyager consome. A 7862 é privada: o host
# não a publica, e existe só para o `launch()` do passo 1 rodar.
#
# Confirmado no Space original (que funciona): no domínio público,
# /ui, /assets, /gradio_api/* e /favicon.ico dão TODOS 404, enquanto
# /v1/models devolve JSON. Ou seja, só a 7860 é exposta.
#
# Consequência prática: se o `uvicorn.run(api, port=PORT)` não subir
# (por exemplo bind falhando), o Gradio da 7862 fica como única
# superfície e o domínio passa a devolver o shell HTML do Gradio em
# TODAS as rotas — inclusive /v1/models. Foi exatamente esse o sintoma
# investigado em https://erinaldordso-mine.hf.space/v1/models.
#
# Tanto PORT quanto GRADIO_PORT são configuráveis por env var.
demo = _ui()
demo.queue(default_concurrency_limit=1)
# Passo 1 — disparar o hook do ZeroGPU. Só `gr.Blocks.launch` chama
# `client.startup_report()`. A UI fica numa porta privada (7862) que o host
# NÃO publica: ela existe apenas para o hook rodar. Verificado no Space
# original — /ui, /assets, /gradio_api/* e /favicon.ico todos dão 404 no
# domínio público, ou seja, a 7862 é invisível de fora.
# prevent_thread_lock=True faz `launch()` RETORNAR em vez de bloquear.
demo.launch(
server_name="0.0.0.0",
server_port=GRADIO_PORT,
prevent_thread_lock=True,
ssr_mode=False,
show_error=True,
)
# Passo 2 — a API ocupa a porta pública e bloqueia até o processo morrer.
# Quem faz o proxy do domínio é o run-loop do host, apontado para cá.
uvicorn.run(api, host="0.0.0.0", port=PORT, log_level="info")