| """Generation backends for the NEMOCITY mind. |
| |
| Protocol (duck-typed; see `Backend`): |
| |
| name: str # "mock" | "llamacpp" | "zerogpu" |
| model_id: str # human-readable model identifier |
| oneshot: bool # always True — NEMOCITY is one-shot only |
| async def generate_stream(prompt: str, grammar: str | None, |
| max_tokens: int) -> AsyncIterator[str] |
| |
| `grammar` is a plain string TAG (mind/validate.py), never a real grammar: |
| "build" | "fix" | "moderation" select the wanted JSON shape (validation + |
| temperature 0.4), a "!strict" suffix means a retry pass at temperature 0, |
| None means free text. The protocol signature is byte-compatible with |
| godseed's so the copied plumbing keeps working. |
| |
| Selected via the CITY_BACKEND env: mock (default) | zerogpu | llamacpp. |
| |
| - MockBackend: deterministic keyword-driven scripts (mind/mock_scripts.py) |
| with small delays so streaming looks alive. Local dev + headless demos. |
| - ZeroGPUBackend: transformers + spaces.GPU, Nemotron-Nano-9B-v2 resident on |
| cuda. ONE @spaces.GPU call per petition (the June 12 lesson). Ship path. |
| - LlamaCppBackend: llama-cpp-python + a local GGUF; offgrid stretch, not the |
| ship path. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import asyncio |
| import concurrent.futures |
| import json |
| import os |
| import re |
| import threading |
| from typing import AsyncIterator, Protocol, runtime_checkable |
|
|
| from . import prompts |
| from .mock_scripts import build_script, fix_script |
| from .validate import ( |
| FIX_TAG, |
| base_tag, |
| is_moderation_grammar, |
| is_strict, |
| parse_build, |
| parse_fix, |
| parse_moderation, |
| ) |
|
|
|
|
| @runtime_checkable |
| class Backend(Protocol): |
| """Structural type for generation backends.""" |
|
|
| name: str |
| model_id: str |
|
|
| def generate_stream( |
| self, prompt: str, grammar: str | None, max_tokens: int |
| ) -> AsyncIterator[str]: ... |
|
|
|
|
| |
| |
| |
|
|
| _MOCK_DENYLIST: tuple[tuple[re.Pattern[str], str], ...] = ( |
| (re.compile(r"\b(nazi|hitler|swastika|kkk|genocide|lynch)\b", re.I), "hate"), |
| (re.compile(r"\b(kill|murder|slaughter|massacre|behead|stab)\b", re.I), "violence"), |
| (re.compile(r"\b(bomb|terror|terrorist|terrorism|shooting)\b", re.I), "violence"), |
| (re.compile(r"\b(rape|porn|sex|sexual|nude|naked|nsfw)\b", re.I), "sexual"), |
| (re.compile(r"\b(suicide|self[- ]?harm|cutting)\b", re.I), "self-harm"), |
| ) |
|
|
|
|
| def _chunks(text: str, size: int) -> list[str]: |
| return [text[i : i + size] for i in range(0, len(text), size)] |
|
|
|
|
| class MockBackend: |
| """Deterministic scripted backend. |
| |
| Routing (marker constants from mind/prompts.py): |
| - moderation tag -> keyword-denylist verdict on the text after |
| CANDIDATE_MARKER; |
| - prompts containing FIX_MARKER -> fix script (reads the rendered |
| candidates/numbers back out of the prompt); |
| - everything else -> BUILD JSON keyed on the petition line after the |
| last PETITION_MARKER. |
| |
| Token delays come from CITY_MOCK_DELAY (seconds per chunk, default |
| 0.012); tests pass delay=0.0 explicitly. |
| """ |
|
|
| name = "mock" |
| model_id = "nemocity-mock-scripts" |
| oneshot = True |
|
|
| def __init__(self, delay: float | None = None): |
| if delay is None: |
| try: |
| delay = float(os.environ.get("CITY_MOCK_DELAY", "0.012")) |
| except ValueError: |
| delay = 0.012 |
| self.delay = max(0.0, delay) |
|
|
| async def _tick(self) -> None: |
| if self.delay: |
| await asyncio.sleep(self.delay) |
| else: |
| await asyncio.sleep(0) |
|
|
| @staticmethod |
| def _tail(prompt: str, marker: str) -> str: |
| parts = prompt.rsplit(marker, 1) |
| return parts[-1] if len(parts) == 2 else prompt |
|
|
| def _petition(self, prompt: str) -> str: |
| tail = self._tail(prompt, prompts.PETITION_MARKER) |
| return (tail.splitlines() or [""])[0].strip() |
|
|
| def _moderate(self, prompt: str) -> dict: |
| tail = self._tail(prompt, prompts.CANDIDATE_MARKER) |
| candidate = (tail.splitlines() or [""])[0].strip() |
| for pattern, category in _MOCK_DENYLIST: |
| if pattern.search(candidate): |
| return {"allowed": False, "category": category} |
| return {"allowed": True, "category": ""} |
|
|
| async def generate_stream( |
| self, prompt: str, grammar: str | None = None, max_tokens: int = 700 |
| ) -> AsyncIterator[str]: |
| if is_moderation_grammar(grammar): |
| verdict = json.dumps(self._moderate(prompt)) |
| for chunk in _chunks(verdict, 8): |
| await self._tick() |
| yield chunk |
| return |
|
|
| if prompts.FIX_MARKER in prompt: |
| payload = fix_script(prompt) |
| else: |
| payload = build_script(self._petition(prompt)) |
| for chunk in _chunks(payload, 12): |
| await self._tick() |
| yield chunk |
|
|
|
|
| |
| |
| |
|
|
| _THREAD_DONE = object() |
|
|
|
|
| class LlamaCppBackend: |
| """llama-cpp-python backend (CPU). No grammars — tags only pick the |
| temperature; the lenient parsers + planner fallback cover messy output. |
| |
| Lazy: nothing is imported or downloaded until the first generate call. |
| Model resolution order: |
| 1. CITY_GGUF env — local path to a .gguf file; |
| 2. hf_hub_download(CITY_GGUF_REPO, CITY_GGUF_FILE). |
| |
| Generation runs in a thread executor; chunks cross into the event loop |
| through a bounded asyncio.Queue (backpressure included). If the consumer |
| abandons the stream early, a cancel flag stops the producer thread. |
| """ |
|
|
| name = "llamacpp" |
| oneshot = True |
|
|
| DEFAULT_REPO = "nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF" |
| DEFAULT_FILE = "NVIDIA-Nemotron3-Nano-4B-Q4_K_M.gguf" |
|
|
| def __init__( |
| self, |
| model_path: str | None = None, |
| n_ctx: int = 4096, |
| n_threads: int | None = None, |
| ): |
| self.model_path = model_path or os.environ.get("CITY_GGUF") or None |
| self.repo_id = os.environ.get("CITY_GGUF_REPO", self.DEFAULT_REPO) |
| self.filename = os.environ.get("CITY_GGUF_FILE", self.DEFAULT_FILE) |
| self.n_ctx = n_ctx |
| self.n_threads = n_threads |
| self.model_id = self.model_path or f"{self.repo_id}:{self.filename}" |
| self._llm = None |
| self._lock = threading.Lock() |
|
|
| |
|
|
| def _ensure_llm(self): |
| with self._lock: |
| if self._llm is not None: |
| return self._llm |
| from llama_cpp import Llama |
|
|
| path = self.model_path |
| if not path or not os.path.exists(path): |
| from huggingface_hub import hf_hub_download |
|
|
| path = hf_hub_download(repo_id=self.repo_id, filename=self.filename) |
| kwargs = {"model_path": path, "n_ctx": self.n_ctx, "verbose": False} |
| threads = self.n_threads or (os.cpu_count() or 4) |
| kwargs["n_threads"] = threads |
| kwargs["n_batch"] = 512 |
| self._llm = Llama(**kwargs) |
| self.model_id = path |
| return self._llm |
|
|
| async def generate_stream( |
| self, prompt: str, grammar: str | None = None, max_tokens: int = 700 |
| ) -> AsyncIterator[str]: |
| loop = asyncio.get_running_loop() |
| queue: asyncio.Queue = asyncio.Queue(maxsize=64) |
| cancelled = threading.Event() |
| if grammar is None: |
| temperature = 0.7 |
| else: |
| temperature = 0.0 if is_strict(grammar) else 0.35 |
|
|
| def _put(item) -> bool: |
| """Push from the producer thread; honor cancellation.""" |
| future = asyncio.run_coroutine_threadsafe(queue.put(item), loop) |
| while not cancelled.is_set(): |
| try: |
| future.result(timeout=0.25) |
| return True |
| except concurrent.futures.TimeoutError: |
| continue |
| except Exception: |
| return False |
| future.cancel() |
| return False |
|
|
| def _produce() -> None: |
| try: |
| llm = self._ensure_llm() |
| stream = llm.create_completion( |
| prompt=prompt, |
| max_tokens=max_tokens, |
| stream=True, |
| temperature=temperature, |
| top_p=0.9, |
| repeat_penalty=1.1, |
| ) |
| for part in stream: |
| if cancelled.is_set(): |
| break |
| text = part["choices"][0].get("text", "") |
| if text and not _put(text): |
| break |
| except Exception as exc: |
| _put(exc) |
| finally: |
| _put(_THREAD_DONE) |
|
|
| producer = loop.run_in_executor(None, _produce) |
| try: |
| while True: |
| item = await queue.get() |
| if item is _THREAD_DONE: |
| break |
| if isinstance(item, Exception): |
| raise item |
| yield item |
| finally: |
| cancelled.set() |
| |
| while not queue.empty(): |
| queue.get_nowait() |
| await producer |
|
|
|
|
| |
| |
| |
|
|
| class ZeroGPUBackend: |
| """transformers + spaces.GPU backend (HF ZeroGPU). |
| |
| All heavy imports (torch, transformers, spaces) are deferred to the first |
| generate call so this module imports cleanly anywhere. The GPU-decorated |
| function is built lazily; if `spaces` is unavailable (local dev) the bare |
| function is used. |
| |
| No grammar support, so when a tag is supplied the output is validated |
| (mind/validate.py) and regenerated once at temperature ~0 on failure. If |
| both attempts are malformed the raw text is yielded anyway — the |
| planner's retry/fallback path handles it from there. |
| |
| Streaming note: tokens are buffered per generation (validation requires |
| the full text) and then yielded in small chunks, so SSE consumers still |
| see incremental output. |
| """ |
|
|
| name = "zerogpu" |
| oneshot = True |
| |
|
|
| |
| |
| |
| |
| DEFAULT_MODEL = "nvidia/NVIDIA-Nemotron-Nano-9B-v2" |
| GPU_DURATION_S = 240 |
|
|
| def __init__(self, model_id: str | None = None, max_input_tokens: int = 4096): |
| self.model_id = model_id or os.environ.get( |
| "CITY_HF_MODEL", self.DEFAULT_MODEL |
| ) |
| self.max_input_tokens = max_input_tokens |
| self._tokenizer = None |
| self._model = None |
| self._gpu_generate = None |
| self._on_cuda = False |
| self._lock = threading.Lock() |
| |
| |
| |
| |
| |
| |
| self._ensure() |
|
|
| |
|
|
| def _ensure(self): |
| with self._lock: |
| if self._gpu_generate is not None: |
| return self._gpu_generate |
| import torch |
|
|
| from transformers import AutoModelForCausalLM, AutoTokenizer |
|
|
| |
| |
| |
| self._tokenizer = AutoTokenizer.from_pretrained(self.model_id) |
| self._model = AutoModelForCausalLM.from_pretrained( |
| self.model_id, |
| dtype=torch.bfloat16, |
| low_cpu_mem_usage=True, |
| ) |
| self._model.eval() |
| |
| |
| |
| |
| |
| |
| |
| try: |
| import spaces |
| self._model.to("cuda") |
| except ImportError: |
| pass |
|
|
| def _generate(prompt: str, max_new_tokens: int, temperature: float) -> str: |
| import torch as _torch |
|
|
| |
| |
| |
| |
| if getattr(self._tokenizer, "chat_template", None): |
| text_in = self._tokenizer.apply_chat_template( |
| [ |
| {"role": "system", "content": "/no_think"}, |
| {"role": "user", "content": prompt}, |
| ], |
| tokenize=False, |
| add_generation_prompt=True, |
| ) |
| else: |
| text_in = prompt |
| inputs = self._tokenizer( |
| text_in, |
| return_tensors="pt", |
| truncation=True, |
| max_length=self.max_input_tokens, |
| ).to(self._model.device) |
| with _torch.no_grad(): |
| output = self._model.generate( |
| **inputs, |
| max_new_tokens=max_new_tokens, |
| do_sample=temperature > 0.05, |
| temperature=max(temperature, 0.05), |
| top_p=0.9, |
| pad_token_id=self._tokenizer.eos_token_id, |
| ) |
| new_tokens = output[0][inputs["input_ids"].shape[1]:] |
| text = self._tokenizer.decode(new_tokens, skip_special_tokens=True) |
| |
| text = re.sub(r"<think>.*?</think>", "", text, flags=re.S) |
| text = text.replace("<think>", "").replace("</think>", "") |
| return text.strip() |
|
|
| try: |
| import spaces |
|
|
| self._gpu_generate = spaces.GPU(duration=self.GPU_DURATION_S)(_generate) |
| except Exception: |
| self._gpu_generate = _generate |
| return self._gpu_generate |
|
|
| @staticmethod |
| def _is_valid(grammar: str, text: str) -> bool: |
| if is_moderation_grammar(grammar): |
| return parse_moderation(text)[1] is None |
| if base_tag(grammar) == FIX_TAG: |
| return parse_fix(text)[1] is None |
| return parse_build(text)[1] is None |
|
|
| async def _gen(self, generate, prompt: str, max_tokens: int, temperature: float) -> str: |
| """Run one generation, retrying transient ZeroGPU CUDA/NVML failures. |
| Each @spaces.GPU call re-schedules onto a (possibly healthier) GPU, so a |
| retry can clear the intermittent NVML allocator assert seen June 12.""" |
| loop = asyncio.get_running_loop() |
| last_exc = None |
| for attempt in range(3): |
| try: |
| return await loop.run_in_executor(None, generate, prompt, max_tokens, temperature) |
| except Exception as exc: |
| msg = str(exc) |
| transient = any(s in msg for s in ("NVML", "CUDA", "cuda", "device", "out of memory")) |
| last_exc = exc |
| if not transient or attempt == 2: |
| raise |
| |
| self._on_cuda = False |
| import torch as _t |
| try: |
| _t.cuda.empty_cache() |
| except Exception: |
| pass |
| await asyncio.sleep(0.5 * (attempt + 1)) |
| raise last_exc |
|
|
| async def generate_stream( |
| self, prompt: str, grammar: str | None = None, max_tokens: int = 700 |
| ) -> AsyncIterator[str]: |
| loop = asyncio.get_running_loop() |
| generate = await loop.run_in_executor(None, self._ensure) |
|
|
| if grammar is None: |
| text = await self._gen(generate, prompt, max_tokens, 0.7) |
| else: |
| temperature = 0.0 if is_strict(grammar) else 0.4 |
| strict_prompt = ( |
| prompt + "\nReply with exactly one JSON object and nothing else." |
| ) |
| text = await self._gen(generate, strict_prompt, max_tokens, temperature) |
| if not self._is_valid(grammar, text): |
| retry_prompt = ( |
| strict_prompt |
| + "\nYour previous output was malformed. Output ONLY the JSON object." |
| ) |
| text = await self._gen(generate, retry_prompt, max_tokens, 0.0) |
| |
|
|
| for chunk in _chunks(text, 24): |
| await asyncio.sleep(0) |
| yield chunk |
|
|
|
|
| |
| |
| |
|
|
| _BACKEND_ALIASES = { |
| "mock": "mock", |
| "llamacpp": "llamacpp", |
| "llama": "llamacpp", |
| "gguf": "llamacpp", |
| "zerogpu": "zerogpu", |
| "zero-gpu": "zerogpu", |
| "transformers": "zerogpu", |
| } |
|
|
|
|
| def make_backend(name: str | None = None): |
| """Build the backend selected by `name` or the CITY_BACKEND env. |
| |
| Defaults to mock (always works, zero deps beyond stdlib). |
| """ |
| raw = (name or os.environ.get("CITY_BACKEND") or "mock").strip().lower() |
| resolved = _BACKEND_ALIASES.get(raw) |
| if resolved == "mock": |
| return MockBackend() |
| if resolved == "llamacpp": |
| return LlamaCppBackend() |
| if resolved == "zerogpu": |
| return ZeroGPUBackend() |
| raise ValueError( |
| f"unknown CITY_BACKEND {raw!r} (expected mock | zerogpu | llamacpp)" |
| ) |
|
|