"""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 do Qwen3-Coder. Devolve (calls, resto).""" if "" not in text: return [], text calls: list[dict[str, Any]] = [] remainder = text pattern = re.compile(r"\s*(\{.*?\})\s*", 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")