Spaces:
Paused
Paused
Download app.py from Erinaldordso/Mine: direct link, hf CLI and curl.
- Browser
- Download file 53.6 kB
-
https://huggingface.co/spaces/Erinaldordso/Mine/resolve/main/app.py
- Command line
-
hf download hf://spaces/Erinaldordso/Mine/app.py
-
curl -L -o app.py https://huggingface.co/spaces/Erinaldordso/Mine/resolve/main/app.py
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" | |
| 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") | |
| 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() | |
| 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", | |
| } | |
| 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, | |
| } | |
| 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(), | |
| } | |
| 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"}, | |
| ], | |
| } | |
| 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}, | |
| } | |
| 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) | |
| 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") | |