atakan
add: Hosted generation for the Space, and retire the ZeroGPU attempt
74544ce
Raw
History Blame Contribute Delete
10.5 kB
"""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 `<tool_call>` 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 <think> 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 `</think>`
# 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 "<think>" in piece:
thinking, think_tokens = True, 0
elif "</think>" 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="<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="</think>", token=-1, thinking=False)
convo = convo + [{"role": "assistant", "content": seen + "</think>"}]
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))