Spaces:
Running on Zero
Running on Zero
| """CUDA inference engine: the same contract as `LocalEngine`, on transformers. | |
| `LocalEngine` is MLX, so it runs on Apple Silicon and nowhere else. The public | |
| demo Space runs on Linux/NVIDIA (ZeroGPU), which needs a second path. This is | |
| that path and nothing more -- it is not a supported way to run ControlAI locally, | |
| and `agent.py`, `registry.py` and everything under `tools/` are untouched by it. | |
| The seam is `ControlAgent(engine=...)`. | |
| It mirrors `LocalEngine`'s design decisions rather than reaching for | |
| `model.generate`: | |
| * **Prefix-reusing KV cache.** `DynamicCache.crop()` trims the cache to the | |
| longest prefix the incoming prompt shares with it, so the ~8k-token | |
| system-prompt-plus-tool-schema prefix is prefilled once per process, not | |
| once per tool step. `model.generate` cannot express that. | |
| * **A hand-written decode loop.** `think_budget` closes an overrunning | |
| `<think>` block by *injecting* the closing token, which no `generate` | |
| callback can do, and `presence_penalty` (not `repetition_penalty`) is | |
| applied for the reason spelled out in `engine.py`: a flat repetition penalty | |
| punishes the `[`, `0`, `,` that matrices and JSON are made of. | |
| **Loading is bf16 and moves to the GPU with an explicit `.to("cuda")`. Do not | |
| reintroduce `device_map` or bitsandbytes, and do not construct this class lazily | |
| at request time.** All three break on ZeroGPU, which is the only place this file | |
| runs, and all three fail the same way: | |
| RuntimeError: Low-level CUDA init (`torch._C._cuda_init`) reached. This | |
| means ZeroGPU's PyTorch CUDA emulation mode did not intercept a CUDA | |
| operation in your code. | |
| ZeroGPU patches torch during the import of the Space's entry module and attaches | |
| real hardware only inside a `@spaces.GPU` call. Only CUDA operations inside that | |
| import window are intercepted, so **where this object is constructed matters as | |
| much as how**: `app_space.py` builds it at module scope for exactly that reason. | |
| `device_map` fails on top of that, because it routes transformers through | |
| `caching_allocator_warmup`, which calls `torch.empty(..., device="cuda")` | |
| directly. bitsandbytes in turn *requires* `device_map`, so 4-bit quantisation is | |
| unavailable here — which is why the model has to be small enough in bf16. | |
| That is why the model is Qwen3-8B rather than the 14B run locally: bf16 8B is | |
| ~16GB to download against ~28GB, and a Space rebuild re-downloads from scratch. | |
| """ | |
| from __future__ import annotations | |
| import os | |
| import time | |
| from typing import Any, Generator, Iterable, Sequence | |
| from controlai_agent.engine import Chunk, SamplingConfig, Stats | |
| def dtype_kwarg(dtype: Any) -> dict[str, Any]: | |
| """`{"dtype": ...}` or `{"torch_dtype": ...}`, whichever this release takes. | |
| transformers renamed the argument in 4.56 and the old spelling is gone in | |
| recent releases. Pinning below 4.56 to keep using it is what broke the Space | |
| build: the platform force-installs gradio 6.x, which requires | |
| huggingface-hub >= 1.16, while every transformers < 4.56 requires < 1.0. | |
| Detecting the spelling costs two lines and pins nothing. | |
| """ | |
| import transformers | |
| major, minor = (int(x) for x in transformers.__version__.split(".")[:2]) | |
| key = "dtype" if (major, minor) >= (4, 56) else "torch_dtype" | |
| return {key: dtype} | |
| # Smaller than the 14B run locally, deliberately: see the module docstring. | |
| DEFAULT_TORCH_MODEL = os.environ.get("CONTROLAI_MODEL_TORCH", "Qwen/Qwen3-8B") | |
| class TorchEngine: | |
| """Streaming generation against a CUDA-resident transformers model.""" | |
| def __init__( | |
| self, | |
| model_id: str = DEFAULT_TORCH_MODEL, | |
| adapter_path: str | None = None, | |
| sampling: SamplingConfig | None = None, | |
| max_cache_tokens: int = 32768, | |
| ) -> None: | |
| import torch | |
| from transformers import AutoModelForCausalLM, AutoTokenizer | |
| self.model_id = model_id | |
| self.adapter_path = adapter_path | |
| self.sampling = sampling or SamplingConfig() | |
| self.max_cache_tokens = max_cache_tokens | |
| t0 = time.time() | |
| self.tokenizer = AutoTokenizer.from_pretrained(model_id) | |
| # No device_map and no quantization_config -- see the module docstring. | |
| # Load to CPU, then move with .to(), which ZeroGPU's emulation intercepts. | |
| # The CPU branch exists so the decode loop can be exercised on a small | |
| # model off a GPU box; it is far too slow to actually serve. | |
| cuda = torch.cuda.is_available() | |
| self.model = AutoModelForCausalLM.from_pretrained( | |
| model_id, **dtype_kwarg(torch.bfloat16 if cuda else torch.float32) | |
| ) | |
| if adapter_path: | |
| from peft import PeftModel | |
| self.model = PeftModel.from_pretrained(self.model, adapter_path) | |
| self.model = self.model.to("cuda" if cuda else "cpu") | |
| self.model.eval() | |
| self.load_seconds = time.time() - t0 | |
| # Verifiable in the Space logs: if this says cpu on the Space, the .to() | |
| # did not take and every request will be minutes rather than seconds. | |
| print(f"[torch] {model_id} on {next(self.model.parameters()).device} " | |
| f"in {self.load_seconds:.1f}s") | |
| self._cache: Any | None = None | |
| self._cache_tokens: list[int] = [] | |
| self.last_stats = Stats() | |
| self.supports_thinking = self._probe_thinking_support() | |
| self._think_open = self._single_token("<think>") | |
| self._think_close = self._single_token("</think>") | |
| self._tool_open = self._single_token("<tool_call>") | |
| self._tool_close = self._single_token("</tool_call>") | |
| # ------------------------------------------------------------------ setup | |
| def _token_ids(self, text: str) -> list[int]: | |
| try: | |
| return self.tokenizer.encode(text, add_special_tokens=False) | |
| except TypeError: | |
| return self.tokenizer.encode(text) | |
| def _single_token(self, text: str) -> int | None: | |
| ids = self._token_ids(text) | |
| return ids[0] if len(ids) == 1 else None | |
| def _probe_thinking_support(self) -> bool: | |
| try: | |
| self.tokenizer.apply_chat_template( | |
| [{"role": "user", "content": "x"}], | |
| tokenize=False, | |
| add_generation_prompt=True, | |
| enable_thinking=False, | |
| ) | |
| return True | |
| except (TypeError, ValueError): | |
| return False | |
| def render( | |
| self, | |
| messages: Sequence[dict[str, Any]], | |
| tools: Sequence[dict[str, Any]] | None = None, | |
| enable_thinking: bool = False, | |
| ) -> str: | |
| kwargs: dict[str, Any] = {"tokenize": False, "add_generation_prompt": True} | |
| if tools: | |
| kwargs["tools"] = list(tools) | |
| if self.supports_thinking: | |
| kwargs["enable_thinking"] = enable_thinking | |
| return self.tokenizer.apply_chat_template(list(messages), **kwargs) | |
| def encode(self, text: str) -> list[int]: | |
| return self.tokenizer.encode(text) | |
| def count_tokens(self, text: str) -> int: | |
| return len(self.tokenizer.encode(text)) | |
| # ------------------------------------------------------------------ cache | |
| def reset_cache(self) -> None: | |
| self._cache = None | |
| self._cache_tokens = [] | |
| def _align_cache(self, tokens: list[int]) -> list[int]: | |
| """Trim the cache to the longest prefix it shares with `tokens`. | |
| Returns the suffix that still has to be fed to the model. Mirrors | |
| `LocalEngine._align_cache`; see that docstring for why this exists. | |
| """ | |
| from transformers import DynamicCache | |
| if self._cache is None or not self._cache_tokens: | |
| self._cache = DynamicCache() | |
| self._cache_tokens = [] | |
| return list(tokens) | |
| shared = 0 | |
| for a, b in zip(self._cache_tokens, tokens): | |
| if a != b: | |
| break | |
| shared += 1 | |
| # Never keep the whole prompt: the model needs at least one token to | |
| # run forward on, or there are no logits to sample from. | |
| if shared >= len(tokens): | |
| shared = len(tokens) - 1 | |
| if shared > self.max_cache_tokens: | |
| shared = 0 | |
| if shared == 0: | |
| self._cache = DynamicCache() | |
| self._cache_tokens = [] | |
| return list(tokens) | |
| if shared < len(self._cache_tokens): | |
| # crop() is what makes prefix reuse possible. It has moved around | |
| # between transformers releases and this file no longer pins a | |
| # version, so losing it costs speed, not correctness: fall back to | |
| # re-prefilling the whole prompt. | |
| if not hasattr(self._cache, "crop"): | |
| self._cache = DynamicCache() | |
| self._cache_tokens = [] | |
| return list(tokens) | |
| self._cache.crop(shared) | |
| self._cache_tokens = list(tokens[:shared]) | |
| return list(tokens[shared:]) | |
| def prewarm(self, text: str) -> int: | |
| """Prefill a prompt prefix so the first real question doesn't pay for it.""" | |
| import torch | |
| tokens = self.encode(text) | |
| to_feed = self._align_cache(tokens) | |
| if to_feed: | |
| with torch.inference_mode(): | |
| self.model( | |
| input_ids=torch.tensor([to_feed], device=self.model.device), | |
| past_key_values=self._cache, | |
| use_cache=True, | |
| ) | |
| self._cache_tokens = list(tokens) | |
| return len(tokens) | |
| # ------------------------------------------------------------- generation | |
| def _sample(self, logits: Any, cfg: SamplingConfig, seen: set[int]) -> int: | |
| import torch | |
| logits = logits.float() | |
| if cfg.presence_penalty and seen: | |
| idx = torch.tensor(sorted(seen), device=logits.device) | |
| logits[idx] -= cfg.presence_penalty | |
| if cfg.temperature <= 0: | |
| return int(torch.argmax(logits).item()) | |
| logits = logits / cfg.temperature | |
| if cfg.top_k and cfg.top_k > 0: | |
| kth = torch.topk(logits, min(cfg.top_k, logits.numel())).values[-1] | |
| logits = logits.masked_fill(logits < kth, float("-inf")) | |
| probs = torch.softmax(logits, dim=-1) | |
| if cfg.top_p and 0 < cfg.top_p < 1: | |
| ordered, order = torch.sort(probs, descending=True) | |
| cumulative = torch.cumsum(ordered, dim=-1) | |
| # Keep the first token that crosses top_p, so the mask is never | |
| # empty even when one token already carries more than top_p mass. | |
| drop = cumulative - ordered > cfg.top_p | |
| ordered[drop] = 0.0 | |
| probs = torch.zeros_like(probs).scatter_(0, order, ordered) | |
| probs = probs / probs.sum() | |
| return int(torch.multinomial(probs, 1).item()) | |
| def stream( | |
| self, | |
| prompt: str | list[int], | |
| sampling: SamplingConfig | None = None, | |
| stop: Iterable[str] = (), | |
| think_budget: int | None = None, | |
| ) -> Generator[Chunk, None, None]: | |
| """Yield output chunks as they are generated. See `LocalEngine.stream`.""" | |
| import torch | |
| cfg = sampling or self.sampling | |
| tokens = self.encode(prompt) if isinstance(prompt, str) else list(prompt) | |
| t0 = time.time() | |
| to_feed = self._align_cache(tokens) | |
| self.last_stats = Stats( | |
| prompt_tokens=len(tokens), | |
| cached_tokens=len(tokens) - len(to_feed), | |
| ) | |
| stop = tuple(s for s in stop if s) | |
| stop_ids = {self._tool_close} if "</tool_call>" in stop and self._tool_close else set() | |
| text_stops = tuple(s for s in stop if not (s == "</tool_call>" and self._tool_close)) | |
| window = max((len(s) for s in text_stops), default=0) + 8 | |
| eos_ids = {self.tokenizer.eos_token_id} | |
| for extra in ("<|im_end|>", "<|endoftext|>"): | |
| tid = self._single_token(extra) | |
| if tid is not None: | |
| eos_ids.add(tid) | |
| eos_ids.discard(None) | |
| emitted: list[int] = [] | |
| seen: set[int] = set() | |
| tail = "" | |
| thinking = False | |
| think_tokens = 0 | |
| prefill_done = False | |
| with torch.inference_mode(): | |
| step_input = to_feed | |
| while len(emitted) < cfg.max_tokens: | |
| out = self.model( | |
| input_ids=torch.tensor([step_input], device=self.model.device), | |
| past_key_values=self._cache, | |
| use_cache=True, | |
| ) | |
| self._cache = out.past_key_values | |
| self._cache_tokens.extend(step_input) | |
| if not prefill_done: | |
| self.last_stats.prefill_seconds = time.time() - t0 | |
| t1 = time.time() | |
| prefill_done = True | |
| token = self._sample(out.logits[0, -1, :], cfg, seen) | |
| if token in eos_ids: | |
| break | |
| seen.add(token) | |
| emitted.append(token) | |
| if token == self._think_open: | |
| thinking, think_tokens = True, 0 | |
| elif token == self._think_close: | |
| thinking = False | |
| elif thinking: | |
| think_tokens += 1 | |
| text = self.tokenizer.decode([token], skip_special_tokens=False) | |
| yield Chunk( | |
| text=text, | |
| token=token, | |
| thinking=thinking, | |
| tool_call=token == self._tool_open, | |
| ) | |
| step_input = [token] | |
| # Overran the reasoning budget: close the block by hand and make | |
| # the model answer. Bounds worst-case latency on a reasoning model. | |
| if ( | |
| thinking | |
| and think_budget | |
| and think_tokens >= think_budget | |
| and self._think_close is not None | |
| ): | |
| thinking = False | |
| emitted.append(self._think_close) | |
| yield Chunk(text="</think>", token=self._think_close, thinking=False) | |
| step_input = [token, self._think_close] | |
| if token in stop_ids: | |
| break | |
| if text_stops: | |
| tail = (tail + text)[-window:] | |
| if any(s in tail for s in text_stops): | |
| break | |
| self.last_stats.generated_tokens = len(emitted) | |
| self.last_stats.decode_seconds = time.time() - (t1 if prefill_done else t0) | |
| def generate( | |
| self, | |
| prompt: str | list[int], | |
| sampling: SamplingConfig | None = None, | |
| stop: Iterable[str] = (), | |
| think_budget: int | None = None, | |
| ) -> str: | |
| return "".join(c.text for c in self.stream(prompt, sampling, stop, think_budget)) | |