"""Remote inference engine: the same contract as `LocalEngine`, over an HTTP API. Why this exists: ZeroGPU is built around "the Gradio app *is* the Space", and ControlAI is a FastAPI app with its own console. Seven distinct incompatibilities came out of trying to bridge that (they are catalogued in CLAUDE.md); the last is structural rather than a bug. This sidesteps the whole category. The Space runs on plain CPU hardware and keeps everything that makes ControlAI what it is -- all 29 deterministic solvers, the verifier, the 80,370-chunk hybrid retriever -- running locally in-process, where they cost milliseconds. Only token generation leaves the machine, to a real GPU behind an API. That is a deliberate trade and it is not free: the demo is no longer self-contained, which is the local app's whole point. The Space page says so. Nothing about `engine.py` or the local Apple Silicon path changes. **The tool schemas go through the model's own chat template locally, and the result is handed over as an ordinary system message** -- never as a `tools=` argument. That matters: a provider given `tools=` applies its own template and returns structured `tool_calls` objects, which the agent does not speak. Doing it this way, the model emits `` as ordinary text and `toolcall.parse` and `_StreamGate` see the same input they see locally. Every provider serving Qwen3 offers `conversational` only, not raw text-generation, so `render()` returns a *message list* rather than a prompt string. The agent treats that value as opaque -- it renders and passes it straight to `stream()` -- so nothing downstream cares which it is. """ from __future__ import annotations import os import time from typing import Any, Generator, Iterable, Sequence from controlai_agent.engine import Chunk, SamplingConfig, Stats DEFAULT_API_MODEL = os.environ.get("CONTROLAI_MODEL_API", "Qwen/Qwen3-14B") # "auto" lets the Hub pick whichever provider currently serves the model. DEFAULT_PROVIDER = os.environ.get("CONTROLAI_PROVIDER", "auto") class RemoteEngine: """Streaming generation against a hosted model, over huggingface_hub.""" def __init__( self, model_id: str = DEFAULT_API_MODEL, adapter_path: str | None = None, sampling: SamplingConfig | None = None, provider: str = DEFAULT_PROVIDER, token: str | None = None, ) -> None: from huggingface_hub import InferenceClient from transformers import AutoTokenizer if adapter_path: raise ValueError( "RemoteEngine cannot load a LoRA adapter: the weights are not local. " "Serve a merged model, or use LocalEngine." ) self.model_id = model_id self.adapter_path = None self.sampling = sampling or SamplingConfig() self.provider = provider t0 = time.time() # Tokenizer only -- no weights. This is what renders the chat template and # counts tokens for history truncation, so it must be the served model's. self.tokenizer = AutoTokenizer.from_pretrained(model_id) self.client = InferenceClient( model=model_id, provider=provider, token=token or os.environ.get("HF_TOKEN"), timeout=300, ) self.load_seconds = time.time() - t0 self.last_stats = Stats() self.supports_thinking = self._probe_thinking_support() print(f"[remote] {model_id} via provider={provider} ({self.load_seconds:.1f}s)") # ------------------------------------------------------------------ setup 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, ) -> list[dict[str, Any]]: """Messages ready for a chat-completions call, tool block folded in. The tool block is not hand-written: the model's own template renders it, and it is lifted back out of the rendered system turn. That keeps the wording identical to the local path even if the template changes. """ out = [dict(m) for m in messages] if tools: rendered = self.tokenizer.apply_chat_template( list(messages), tools=list(tools), tokenize=False, add_generation_prompt=True ) head, tail = "<|im_start|>system\n", "<|im_end|>" start = rendered.find(head) end = rendered.find(tail, start) if start >= 0 else -1 if start >= 0 and end > start: system = rendered[start + len(head):end] out = [m for m in out if m.get("role") != "system"] out.insert(0, {"role": "system", "content": system}) if not enable_thinking and self.supports_thinking: # Qwen3's documented soft switch. `chat_template_kwargs` would be the # direct equivalent, but it is an `extra_body` passthrough that not # every provider forwards; this is in the prompt and always arrives. for m in reversed(out): if m.get("role") == "user": m["content"] = f"{m['content']} /no_think" break return out 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: """No-op. The KV cache lives on the provider's side, out of reach.""" def prewarm(self, text: str) -> int: """No-op beyond reporting size. `LocalEngine.prewarm` prefills the shared prefix into a persistent cache. There is no cache to prefill here, and the provider's own prefix caching is not ours to manage, so this only reports what the prefix costs. """ return self.count_tokens(text) # ------------------------------------------------------------- generation def _request(self, messages: list[dict[str, Any]], cfg: SamplingConfig, stop: list[str], budget: int): return self.client.chat_completion( messages=messages, stream=True, max_tokens=max(budget, 1), temperature=cfg.temperature if cfg.temperature > 0 else None, top_p=cfg.top_p if 0 < cfg.top_p < 1 else None, # Natively supported here, so this matches engine.py exactly rather # than approximating it -- and a flat repetition_penalty stays out, # for the reason engine.py gives. presence_penalty=cfg.presence_penalty or None, stop=stop or None, ) 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 arrive. See `LocalEngine.stream`.""" cfg = sampling or self.sampling convo = prompt if isinstance(prompt, list) and prompt and isinstance(prompt[0], dict) else [ {"role": "user", "content": prompt if isinstance(prompt, str) else self.tokenizer.decode(prompt)} ] stops = [s for s in stop if s] t0 = time.time() self.last_stats = Stats( prompt_tokens=sum(self.count_tokens(str(m.get("content", ""))) for m in convo) ) first_token_at: float | None = None emitted = 0 thinking = False think_tokens = 0 seen = "" # Runs at most twice: once normally, and again if the reasoning budget # was overrun. `LocalEngine` closes an overrunning block by # injecting the closing token mid-stream; there is no mid-stream here, so # the equivalent is to stop, hand back what was generated with `` # appended, and let a continuation produce the answer. Same transcript, # one extra round trip. for attempt in (0, 1): overran = False for event in self._request(convo, cfg, stops, cfg.max_tokens - emitted): try: piece = event.choices[0].delta.content except (AttributeError, IndexError, TypeError): piece = None if not piece: continue if first_token_at is None: first_token_at = time.time() self.last_stats.prefill_seconds = first_token_at - t0 seen += piece if "" in piece: thinking, think_tokens = True, 0 elif "" in piece: thinking = False elif thinking: think_tokens += 1 emitted += 1 yield Chunk( text=piece, token=-1, # the API returns text, not ids thinking=thinking, tool_call="" in piece, ) if thinking and think_budget and think_tokens >= think_budget: overran = True break if emitted >= cfg.max_tokens: break if not (overran and attempt == 0): break yield Chunk(text="", token=-1, thinking=False) convo = convo + [{"role": "assistant", "content": seen + ""}] thinking = False self.last_stats.generated_tokens = emitted self.last_stats.decode_seconds = time.time() - (first_token_at or 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))