Spaces:
Running on Zero
add: Hosted generation for the Space, and retire the ZeroGPU attempt
Browse filesThe Space moves to plain CPU hardware with generation over Inference Providers.
Everything that makes ControlAI what it is stays in-process -- all 29
deterministic solvers, the verifier, the full 80,370-chunk hybrid retriever --
because those cost milliseconds on a CPU. Only token generation leaves, to a GPU
behind an API. That is a real trade: the demo is no longer self-contained, which
is the local app's whole point, so the Space page says so.
engine_api.py implements the same engine contract. It renders the tool schemas
through the model's own chat template locally and passes the result as an
ordinary system message, never as tools=: a provider given tools= applies its own
template and returns structured tool_calls, which the agent does not speak. This
way the model emits <tool_call> as text and toolcall.parse sees what it sees
locally. Every provider serving Qwen3 offers conversational only, not raw
text-generation, so render() returns a message list instead of a string -- the
agent treats that value as opaque, so nothing downstream changes. Thinking is
disabled via Qwen3's /no_think soft switch rather than chat_template_kwargs,
which is an extra_body passthrough not every provider forwards.
app_space.py drops from 200 lines to 60: no spaces, no Gradio probe, no
import-window choreography, and the /api/gpudiag debug route is gone with it.
requirements-space.txt uses CPU torch wheels, still needed for the 0.6B
retrieval embedder. Embedder now treats every non-MLX backend as transformers,
since "api" describes where generation happens and says nothing about embedding.
engine_torch.py stays: it works on an ordinary CUDA box, and it is ZeroGPU
specifically that does not fit. CLAUDE.md records all seven incompatibilities,
ending with the structural one -- ZeroGPU schedules GPU workers by forking the
Gradio server process and assumes the Gradio app is the Space, while this is a
FastAPI app that merely borrows the process. Written down so the attempt is not
repeated blind.
Verified locally up to the auth boundary: the engine renders, folds the tool
block in, applies /no_think, and reaches the provider endpoint. It returns 403
because the current token lacks the Inference Providers permission, which is a
token setting, not code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- CLAUDE.md +47 -82
- app.py +6 -2
- app_space.py +29 -206
- controlai_agent/engine_api.py +249 -0
- controlai_rag/embeddings.py +6 -1
- requirements-space.txt +21 -22
|
@@ -179,88 +179,53 @@ formats correctly. `CONTROLAI_ADAPTER=<path>` loads one anyway for A/B work.
|
|
| 179 |
discovery → extraction → dataset generation → training → evaluation). That pipeline is independent
|
| 180 |
of the serving path and uses `requirements-training.txt`/`requirements-corpus.txt`.
|
| 181 |
|
| 182 |
-
### Deployment (`app_space.py`, `
|
| 183 |
-
The demo Space
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
CUDA
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
executor on the backend: MLX keeps its single pinned thread, torch gets direct calls under the lock
|
| 230 |
-
and hands `/api/chat/stream` a plain sync generator for Starlette's own threadpool. **This was found
|
| 231 |
-
and fixed once before, in 4de16e3, and the MLX rewrite reintroduced it** — the executor was made
|
| 232 |
-
unconditional because the CUDA path had been deleted. Read that commit before touching threading
|
| 233 |
-
here.
|
| 234 |
-
|
| 235 |
-
**ZeroGPU platform gotchas, each learned by having the Space fail:**
|
| 236 |
-
- A `@spaces.GPU` function is only detected if wired to a real Gradio event handler. One called
|
| 237 |
-
solely from a FastAPI route fails startup with "No @spaces.GPU function detected". Hence the
|
| 238 |
-
hidden probe button.
|
| 239 |
-
- That function must be a module-level `def`; nested inside `with gr.Blocks():` the detection fails.
|
| 240 |
-
- Don't `mount_gradio_app()` the probe and then run uvicorn on the same port — Gradio's own server
|
| 241 |
-
setup collides ("address already in use"). The probe launches on `port + 1`, non-blocking.
|
| 242 |
-
- **Never pass the model-holding object as an argument** to a `@spaces.GPU` function. ZeroGPU
|
| 243 |
-
marshals arguments across a process boundary and tries to share the model's CUDA tensors, failing
|
| 244 |
-
with `_share_cuda_: only available on CUDA` after emitting nothing — which reads exactly like
|
| 245 |
-
"just slow". Reach the agent through the module global.
|
| 246 |
-
|
| 247 |
-
**Do not put an upper bound in `requirements-space.txt`.** The platform appends its own
|
| 248 |
-
`gradio[oauth,mcp]`, `spaces`, `uvicorn` and a `torch` ceiling to whatever that file asks for, and
|
| 249 |
-
one extra constraint can make the resolve impossible. Pinning `transformers<4.56` — to keep using
|
| 250 |
-
the `torch_dtype=` kwarg it renamed — failed the build outright, because gradio 6.x requires
|
| 251 |
-
`huggingface-hub>=1.16` and every `transformers<4.56` requires `<1.0`. `engine_torch.dtype_kwarg()`
|
| 252 |
-
detects the spelling instead, and `_align_cache` falls back to a full re-prefill if `DynamicCache`
|
| 253 |
-
has no `crop()`, so an unpinned transformers costs speed rather than correctness.
|
| 254 |
-
|
| 255 |
-
The Space needs `HF_TOKEN` as a secret: the retrieval index is in a private dataset repo and
|
| 256 |
-
`app_space.py::_fetch_index` pulls it at startup. Without it the Space still boots, and answers
|
| 257 |
-
from model knowledge alone.
|
| 258 |
-
|
| 259 |
-
**The Space's queries are embedded with the bf16 `Qwen/Qwen3-Embedding-0.6B` while the index was
|
| 260 |
-
built with the MLX 4-bit checkpoint.** Measured, the two produce vectors agreeing at cosine 0.96,
|
| 261 |
-
and against the real 80,370-chunk index the gate behaves the same: in-domain worst best-match 0.712
|
| 262 |
-
(MLX 0.708), off-domain best 0.528 (MLX 0.541). `MIN_COSINE = 0.62` therefore holds unchanged on
|
| 263 |
-
both. `CONTROLAI_MIN_COSINE` overrides it if that ever drifts.
|
| 264 |
|
| 265 |
### Benchmark (`benchmarks/`)
|
| 266 |
`controlbench_v1.jsonl` is the eval set; `SCOPE.md` defines the taxonomy and `README.md` the
|
|
|
|
| 179 |
discovery → extraction → dataset generation → training → evaluation). That pipeline is independent
|
| 180 |
of the serving path and uses `requirements-training.txt`/`requirements-corpus.txt`.
|
| 181 |
|
| 182 |
+
### Deployment (`app_space.py`, `engine_api.py`, `requirements-space.txt`)
|
| 183 |
+
The demo Space runs **plain CPU hardware with hosted generation**, not ZeroGPU.
|
| 184 |
+
`CONTROLAI_BACKEND=api` makes `app.py::_make_engine` build `RemoteEngine`; everything else —
|
| 185 |
+
all 29 solvers, the verifier, the full 80,370-chunk retriever — runs in-process on the Space's CPU,
|
| 186 |
+
where it costs milliseconds. Only token generation leaves the machine. The Space page says so
|
| 187 |
+
plainly, because the local app's whole point is that nothing does.
|
| 188 |
+
|
| 189 |
+
`engine_api.py` renders the tool schemas through the model's own chat template locally and hands
|
| 190 |
+
the result over as an ordinary **system message** — never as a `tools=` argument, which would make
|
| 191 |
+
the provider apply its own template and return structured `tool_calls` the agent does not speak.
|
| 192 |
+
Every provider serving Qwen3 offers `conversational` only, not raw text-generation, so `render()`
|
| 193 |
+
returns a *message list* rather than a string; the agent treats that value as opaque, so nothing
|
| 194 |
+
downstream cares. Thinking is disabled with Qwen3's `/no_think` soft switch rather than
|
| 195 |
+
`chat_template_kwargs`, which is an `extra_body` passthrough not every provider forwards.
|
| 196 |
+
|
| 197 |
+
`HF_TOKEN` must be a Space secret with **two** permissions: read on the private index dataset repo,
|
| 198 |
+
and *Make calls to Inference Providers*. Missing the first disables retrieval silently; missing the
|
| 199 |
+
second fails generation with `403 ... does not have sufficient permissions to call Inference
|
| 200 |
+
Providers`.
|
| 201 |
+
|
| 202 |
+
**ZeroGPU was tried at length and abandoned.** `engine_torch.py` is kept and works on an ordinary
|
| 203 |
+
CUDA box; it is ZeroGPU specifically that does not fit. Seven distinct problems, recorded so the
|
| 204 |
+
attempt is not repeated blind:
|
| 205 |
+
1. **Dependency resolution.** The platform appends its own `gradio[oauth,mcp]`, `spaces`, `uvicorn`
|
| 206 |
+
and a `torch` ceiling to `requirements.txt`. Any upper bound of your own can make the resolve
|
| 207 |
+
impossible — `transformers<4.56` did, because gradio 6 needs `huggingface-hub>=1.16` and every
|
| 208 |
+
`transformers<4.56` needs `<1.0`.
|
| 209 |
+
2. **Import-window loading.** ZeroGPU patches torch during the entry module's import and only
|
| 210 |
+
intercepts CUDA inside that window. Building the model in FastAPI's `lifespan`, on a worker
|
| 211 |
+
thread, reaches real CUDA init and raises.
|
| 212 |
+
3. **`device_map` and bitsandbytes.** `device_map` routes transformers through
|
| 213 |
+
`caching_allocator_warmup`'s direct `torch.empty(..., device="cuda")`, which trips the same
|
| 214 |
+
guard; bitsandbytes requires `device_map`, so 4-bit is unavailable.
|
| 215 |
+
4. **Inference must be inside `@spaces.GPU`.** Otherwise the packed tensors are never materialised
|
| 216 |
+
and forward passes return fluent multilingual noise at ~15 min/turn — up, responsive, and
|
| 217 |
+
confidently wrong.
|
| 218 |
+
5. **Thread context.** `spaces` intercepts CUDA only in the context it manages; a call from a
|
| 219 |
+
custom `ThreadPoolExecutor` thread fails with `No CUDA GPUs are available` even with a GPU
|
| 220 |
+
attached. Found and fixed once before in 4de16e3, then reintroduced by the MLX rewrite.
|
| 221 |
+
6. **The probe must be `launch()`ed, not mounted.** `gr.mount_gradio_app` fails startup with
|
| 222 |
+
"No @spaces.GPU function detected". Launch it on its own port with `ssr_mode=False` (Gradio 6's
|
| 223 |
+
SSR spawns a Node subprocess into the process ZeroGPU forks from).
|
| 224 |
+
7. **The structural one.** With all six fixed, the GPU is scheduled and acquired and ZeroGPU's own
|
| 225 |
+
forked worker still dies in `torch.init()`. A no-op `@spaces.GPU` function fails identically, so
|
| 226 |
+
it is not the model or the payload. Meanwhile the platform probes the public port for Gradio's
|
| 227 |
+
`/api/predict` and gets 404, because FastAPI owns it. ZeroGPU assumes the Gradio app *is* the
|
| 228 |
+
Space; this one is not.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
|
| 230 |
### Benchmark (`benchmarks/`)
|
| 231 |
`controlbench_v1.jsonl` is the eval set; `SCOPE.md` defines the taxonomy and `README.md` the
|
|
@@ -51,7 +51,7 @@ for directory in (STATIC_DIR, PLOTS_DIR, UPLOADS_DIR):
|
|
| 51 |
# One thread, for the lifetime of the process: see the module docstring.
|
| 52 |
def _uses_mlx() -> bool:
|
| 53 |
return os.environ.get("CONTROLAI_BACKEND", "mlx").strip().lower() not in (
|
| 54 |
-
"torch", "pytorch", "cuda",
|
| 55 |
)
|
| 56 |
|
| 57 |
|
|
@@ -87,6 +87,10 @@ def _make_engine():
|
|
| 87 |
registry, every tool -- is identical either way.
|
| 88 |
"""
|
| 89 |
backend = os.environ.get("CONTROLAI_BACKEND", "mlx").strip().lower()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
# "pytorch" is what the old orchestrator called this and it survives as a
|
| 91 |
# variable on the deployed Space; "cuda" is the obvious other guess.
|
| 92 |
if backend in ("torch", "pytorch", "cuda"):
|
|
@@ -100,7 +104,7 @@ def _make_engine():
|
|
| 100 |
# MLX and died in an import several frames deeper than the real cause.
|
| 101 |
raise ValueError(
|
| 102 |
f"CONTROLAI_BACKEND={backend!r} is not a known backend "
|
| 103 |
-
f"(expected one of: mlx, torch/pytorch/cuda)"
|
| 104 |
)
|
| 105 |
return None # ControlAgent's own default, LocalEngine
|
| 106 |
|
|
|
|
| 51 |
# One thread, for the lifetime of the process: see the module docstring.
|
| 52 |
def _uses_mlx() -> bool:
|
| 53 |
return os.environ.get("CONTROLAI_BACKEND", "mlx").strip().lower() not in (
|
| 54 |
+
"torch", "pytorch", "cuda", "api", "remote", "hosted",
|
| 55 |
)
|
| 56 |
|
| 57 |
|
|
|
|
| 87 |
registry, every tool -- is identical either way.
|
| 88 |
"""
|
| 89 |
backend = os.environ.get("CONTROLAI_BACKEND", "mlx").strip().lower()
|
| 90 |
+
if backend in ("api", "remote", "hosted"):
|
| 91 |
+
from controlai_agent.engine_api import RemoteEngine
|
| 92 |
+
|
| 93 |
+
return RemoteEngine()
|
| 94 |
# "pytorch" is what the old orchestrator called this and it survives as a
|
| 95 |
# variable on the deployed Space; "cuda" is the obvious other guess.
|
| 96 |
if backend in ("torch", "pytorch", "cuda"):
|
|
|
|
| 104 |
# MLX and died in an import several frames deeper than the real cause.
|
| 105 |
raise ValueError(
|
| 106 |
f"CONTROLAI_BACKEND={backend!r} is not a known backend "
|
| 107 |
+
f"(expected one of: mlx, torch/pytorch/cuda, api/remote/hosted)"
|
| 108 |
)
|
| 109 |
return None # ControlAgent's own default, LocalEngine
|
| 110 |
|
|
@@ -1,90 +1,43 @@
|
|
| 1 |
-
"""Hugging Face Space entry point:
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
worker's "No CUDA GPUs are available".
|
| 23 |
-
4. Never pass the model-holding object as an *argument* to a `@spaces.GPU`
|
| 24 |
-
function. ZeroGPU marshals arguments across a process boundary and will try
|
| 25 |
-
to share the model's CUDA tensors, failing with `_share_cuda_: only
|
| 26 |
-
available on CUDA` after producing no output at all -- which reads exactly
|
| 27 |
-
like "just slow". Reach the agent through the module-level global instead.
|
| 28 |
-
|
| 29 |
-
The retrieval index is not in the repo (see CLAUDE.md), so it is pulled from the
|
| 30 |
-
private Hub dataset repo at startup using the Space's HF_TOKEN secret. Without
|
| 31 |
-
it the retriever has no corpus and every answer falls back to model knowledge.
|
| 32 |
"""
|
| 33 |
|
| 34 |
from __future__ import annotations
|
| 35 |
|
| 36 |
import os
|
| 37 |
-
from typing import Iterator
|
| 38 |
|
| 39 |
-
#
|
| 40 |
-
#
|
| 41 |
-
# -- CONTROLAI_BACKEND=pytorch, left
|
| 42 |
-
|
| 43 |
-
os.environ["CONTROLAI_BACKEND"] = "torch"
|
| 44 |
-
# Qwen3-14B in 4-bit NF4. Override with CONTROLAI_MODEL_TORCH in Space settings.
|
| 45 |
|
| 46 |
-
import gradio as gr
|
| 47 |
-
import spaces
|
| 48 |
import uvicorn
|
| 49 |
|
| 50 |
import app as app_module
|
| 51 |
from app import app
|
| 52 |
|
| 53 |
-
# Filled in at import, before and after the model load, so the parent's CUDA
|
| 54 |
-
# state can be compared against the worker's. See /api/gpudiag.
|
| 55 |
-
_parent_state: dict = {}
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
def _torch_state(label: str) -> dict:
|
| 59 |
-
import os as _os
|
| 60 |
-
|
| 61 |
-
import torch
|
| 62 |
-
|
| 63 |
-
state = {
|
| 64 |
-
"where": label,
|
| 65 |
-
"torch": torch.__version__,
|
| 66 |
-
"torch.version.cuda": torch.version.cuda,
|
| 67 |
-
"CUDA_VISIBLE_DEVICES": _os.environ.get("CUDA_VISIBLE_DEVICES"),
|
| 68 |
-
"ZERO_GPU_PATCH_TORCH": _os.environ.get("ZERO_GPU_PATCH_TORCH"),
|
| 69 |
-
"pid": _os.getpid(),
|
| 70 |
-
}
|
| 71 |
-
# is_initialized() is the one that matters: if the parent has really
|
| 72 |
-
# initialised CUDA before ZeroGPU forks its worker, the child cannot use the
|
| 73 |
-
# GPU and reports "No CUDA GPUs are available" -- which is our exact error.
|
| 74 |
-
for name, fn in (
|
| 75 |
-
("cuda.is_initialized", lambda: torch.cuda.is_initialized()),
|
| 76 |
-
("cuda.is_available", lambda: torch.cuda.is_available()),
|
| 77 |
-
("cuda.device_count", lambda: torch.cuda.device_count()),
|
| 78 |
-
):
|
| 79 |
-
try:
|
| 80 |
-
state[name] = fn()
|
| 81 |
-
except Exception as exc: # noqa: BLE001 - the message is the datum
|
| 82 |
-
state[name] = f"{type(exc).__name__}: {exc}"
|
| 83 |
-
return state
|
| 84 |
-
|
| 85 |
|
| 86 |
def _fetch_index() -> None:
|
| 87 |
-
"""Pull the retrieval index
|
| 88 |
from controlai_rag.fetch_index import fetch
|
| 89 |
|
| 90 |
if not os.environ.get("HF_TOKEN"):
|
|
@@ -97,140 +50,10 @@ def _fetch_index() -> None:
|
|
| 97 |
print(f"[space] could not fetch the index ({exc}); retrieval disabled")
|
| 98 |
|
| 99 |
|
| 100 |
-
def _build_agent_at_import() -> None:
|
| 101 |
-
"""Construct the agent *now*, while this module is still being imported.
|
| 102 |
-
|
| 103 |
-
ZeroGPU installs its CUDA emulation by patching torch during the import of
|
| 104 |
-
the Space's entry module, and only operations that happen inside that window
|
| 105 |
-
are intercepted. `app.py` normally builds the model in FastAPI's `lifespan`,
|
| 106 |
-
on a ThreadPoolExecutor worker -- long after import, on another thread, where
|
| 107 |
-
the patching does not apply. `.to("cuda")` there reaches the real
|
| 108 |
-
`torch._C._cuda_init` and raises:
|
| 109 |
-
|
| 110 |
-
RuntimeError: Low-level CUDA init (`torch._C._cuda_init`) reached.
|
| 111 |
-
|
| 112 |
-
Building here and handing the finished agent to `app.py` keeps the load
|
| 113 |
-
inside the window. `lifespan` then finds `_agent` already set and its
|
| 114 |
-
`get_agent()` is a no-op.
|
| 115 |
-
"""
|
| 116 |
-
from controlai_agent.agent import ControlAgent
|
| 117 |
-
from controlai_agent.engine_torch import TorchEngine
|
| 118 |
-
from controlai_rag.embeddings import get_embedder
|
| 119 |
-
|
| 120 |
-
_parent_state["before_model_load"] = _torch_state("parent-before-load")
|
| 121 |
-
print("[space] building agent at import scope (ZeroGPU CUDA window)")
|
| 122 |
-
app_module._agent = ControlAgent(engine=TorchEngine())
|
| 123 |
-
|
| 124 |
-
# The retrieval embedder is a second model, and it loads lazily on first
|
| 125 |
-
# query -- which is a request, outside the import window, so it hit exactly
|
| 126 |
-
# the same _cuda_init wall and every answer came back with
|
| 127 |
-
# "[agent] retrieval failed". Force it onto the GPU here too. Embedding one
|
| 128 |
-
# throwaway string is what actually triggers the load.
|
| 129 |
-
get_embedder().encode_query("warmup")
|
| 130 |
-
# Route every turn through the GPU-decorated generator above.
|
| 131 |
-
app_module.stream_hook = _gpu_stream
|
| 132 |
-
_parent_state["after_model_load"] = _torch_state("parent-after-load")
|
| 133 |
-
print(f"[space] parent CUDA state after load: {_parent_state['after_model_load']}")
|
| 134 |
-
print("[space] agent and embedder ready, GPU stream hook installed")
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
# One allocation per user turn, not per engine call. A turn is up to
|
| 138 |
-
# MAX_TOOL_STEPS generations plus the tool executions between them, and they all
|
| 139 |
-
# mutate the same KV cache; splitting them across separate @spaces.GPU calls
|
| 140 |
-
# would put that shared state on the far side of a process boundary each time.
|
| 141 |
-
# 300s is the ceiling for a turn that actually calls two tools.
|
| 142 |
-
@spaces.GPU(duration=300)
|
| 143 |
-
def _gpu_stream(message: str, history: list) -> Iterator[dict]:
|
| 144 |
-
"""Run one whole agent turn with real hardware attached.
|
| 145 |
-
|
| 146 |
-
Everything about ControlAI's inference has to happen inside a call like this
|
| 147 |
-
one. ZeroGPU creates the model under CUDA *emulation* at import and packs its
|
| 148 |
-
tensors; only a @spaces.GPU call materialises them on a real device. Running
|
| 149 |
-
the forward passes anywhere else does not fail loudly -- it reads
|
| 150 |
-
unmaterialised tensors and returns fluent-looking multilingual noise at a few
|
| 151 |
-
minutes per turn, which is exactly what the Space did before this existed.
|
| 152 |
-
|
| 153 |
-
`message` and `history` are plain data. The agent is reached through the
|
| 154 |
-
module global and never passed in: ZeroGPU marshals arguments across a
|
| 155 |
-
process boundary and would try to share the model's CUDA tensors, hanging
|
| 156 |
-
with no output at all.
|
| 157 |
-
"""
|
| 158 |
-
yield from app_module.get_agent().stream(message, history)
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
@spaces.GPU(duration=60)
|
| 162 |
-
def _gpu_diagnostics() -> dict:
|
| 163 |
-
"""Report CUDA state from inside the ZeroGPU worker, where it fails."""
|
| 164 |
-
import subprocess
|
| 165 |
-
|
| 166 |
-
state = _torch_state("gpu-worker")
|
| 167 |
-
try:
|
| 168 |
-
state["nvidia-smi"] = subprocess.run(
|
| 169 |
-
["nvidia-smi", "--query-gpu=name,driver_version", "--format=csv,noheader"],
|
| 170 |
-
capture_output=True, text=True, timeout=20,
|
| 171 |
-
).stdout.strip() or "(no output)"
|
| 172 |
-
except Exception as exc: # noqa: BLE001
|
| 173 |
-
state["nvidia-smi"] = f"{type(exc).__name__}: {exc}"
|
| 174 |
-
return state
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
@app.get("/api/gpudiag")
|
| 178 |
-
def gpudiag() -> dict:
|
| 179 |
-
"""Compare parent-process CUDA state with the @spaces.GPU worker's.
|
| 180 |
-
|
| 181 |
-
A plain `def`, so Starlette runs it on its own threadpool -- the same
|
| 182 |
-
context the real inference path uses.
|
| 183 |
-
"""
|
| 184 |
-
try:
|
| 185 |
-
worker = _gpu_diagnostics()
|
| 186 |
-
except Exception as exc: # noqa: BLE001
|
| 187 |
-
worker = {"error": f"{type(exc).__name__}: {exc}"}
|
| 188 |
-
return {
|
| 189 |
-
"parent_at_import": _parent_state,
|
| 190 |
-
"parent_now": _torch_state("parent-now"),
|
| 191 |
-
"worker": worker,
|
| 192 |
-
}
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
@spaces.GPU(duration=60)
|
| 196 |
-
def _gpu_probe(text: str) -> str:
|
| 197 |
-
"""Satisfies ZeroGPU's startup validation.
|
| 198 |
-
|
| 199 |
-
A @spaces.GPU function is only detected if it is wired to a real Gradio event
|
| 200 |
-
handler, and _gpu_stream is driven by FastAPI rather than by Gradio, so it
|
| 201 |
-
cannot serve that purpose itself. This one exists to be wired to the hidden
|
| 202 |
-
button below.
|
| 203 |
-
"""
|
| 204 |
-
return "ready"
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
# Both run at import, in this order: the index first (the agent touches retrieval
|
| 208 |
-
# as it starts), then the agent -- which installs _gpu_stream, so it has to come
|
| 209 |
-
# after that function exists.
|
| 210 |
-
_fetch_index()
|
| 211 |
-
_build_agent_at_import()
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
with gr.Blocks() as _gpu_demo:
|
| 215 |
-
_probe_in = gr.Textbox(visible=False)
|
| 216 |
-
_probe_out = gr.Textbox(visible=False)
|
| 217 |
-
_probe_btn = gr.Button("probe", visible=False)
|
| 218 |
-
_probe_btn.click(fn=_gpu_probe, inputs=_probe_in, outputs=_probe_out)
|
| 219 |
-
|
| 220 |
-
|
| 221 |
def main() -> None:
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
# Node subprocess lands in the process ZeroGPU forks its CUDA worker from.
|
| 226 |
-
_gpu_demo.launch(
|
| 227 |
-
server_name="0.0.0.0",
|
| 228 |
-
server_port=port + 1,
|
| 229 |
-
prevent_thread_lock=True,
|
| 230 |
-
share=False,
|
| 231 |
-
ssr_mode=False,
|
| 232 |
-
)
|
| 233 |
-
uvicorn.run(app, host="0.0.0.0", port=port, log_level="info")
|
| 234 |
|
| 235 |
|
| 236 |
if __name__ == "__main__":
|
|
|
|
| 1 |
+
"""Hugging Face Space entry point: FastAPI, CPU hardware, hosted generation.
|
| 2 |
+
|
| 3 |
+
`./run.sh` runs `app.py` directly; this exists only for the Space.
|
| 4 |
+
|
| 5 |
+
**This deliberately does not use ZeroGPU.** Seven separate incompatibilities came
|
| 6 |
+
out of trying to, and the last one is structural rather than a bug: ZeroGPU is
|
| 7 |
+
built around "the Gradio app *is* the Space", scheduling GPU workers by forking
|
| 8 |
+
the Gradio server process, while ControlAI is a FastAPI app with its own console
|
| 9 |
+
that merely borrows the process. The findings are catalogued in CLAUDE.md so the
|
| 10 |
+
attempt is not repeated blind, and `engine_torch.py` is kept for anyone running
|
| 11 |
+
this on an ordinary CUDA box, where all of it works.
|
| 12 |
+
|
| 13 |
+
What runs where: everything that makes ControlAI what it is stays in-process on
|
| 14 |
+
the Space's CPU -- all 29 deterministic solvers, the verifier, the full
|
| 15 |
+
80,370-chunk hybrid retriever. Those cost milliseconds. Only token generation
|
| 16 |
+
leaves, to a GPU behind an API, via `RemoteEngine`.
|
| 17 |
+
|
| 18 |
+
The Space needs `HF_TOKEN` as a secret, with **two** permissions: read access to
|
| 19 |
+
the private dataset repo holding the index, and "Make calls to Inference
|
| 20 |
+
Providers". Without the first, retrieval is silently disabled; without the
|
| 21 |
+
second, generation fails with 403.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
"""
|
| 23 |
|
| 24 |
from __future__ import annotations
|
| 25 |
|
| 26 |
import os
|
|
|
|
| 27 |
|
| 28 |
+
# Must precede the `app` import: it decides which engine gets built. Assigned,
|
| 29 |
+
# not setdefault-ed, because a stale Space variable must not select something
|
| 30 |
+
# else -- CONTROLAI_BACKEND=pytorch, left from the old orchestrator, once did.
|
| 31 |
+
os.environ["CONTROLAI_BACKEND"] = "api"
|
|
|
|
|
|
|
| 32 |
|
|
|
|
|
|
|
| 33 |
import uvicorn
|
| 34 |
|
| 35 |
import app as app_module
|
| 36 |
from app import app
|
| 37 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
def _fetch_index() -> None:
|
| 40 |
+
"""Pull the retrieval index in before the agent starts."""
|
| 41 |
from controlai_rag.fetch_index import fetch
|
| 42 |
|
| 43 |
if not os.environ.get("HF_TOKEN"):
|
|
|
|
| 50 |
print(f"[space] could not fetch the index ({exc}); retrieval disabled")
|
| 51 |
|
| 52 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
def main() -> None:
|
| 54 |
+
_fetch_index()
|
| 55 |
+
uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", 7860)),
|
| 56 |
+
log_level="info")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
|
| 58 |
|
| 59 |
if __name__ == "__main__":
|
|
@@ -0,0 +1,249 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Remote inference engine: the same contract as `LocalEngine`, over an HTTP API.
|
| 2 |
+
|
| 3 |
+
Why this exists: ZeroGPU is built around "the Gradio app *is* the Space", and
|
| 4 |
+
ControlAI is a FastAPI app with its own console. Seven distinct incompatibilities
|
| 5 |
+
came out of trying to bridge that (they are catalogued in CLAUDE.md); the last is
|
| 6 |
+
structural rather than a bug. This sidesteps the whole category. The Space runs
|
| 7 |
+
on plain CPU hardware and keeps everything that makes ControlAI what it is --
|
| 8 |
+
all 29 deterministic solvers, the verifier, the 80,370-chunk hybrid retriever --
|
| 9 |
+
running locally in-process, where they cost milliseconds. Only token generation
|
| 10 |
+
leaves the machine, to a real GPU behind an API.
|
| 11 |
+
|
| 12 |
+
That is a deliberate trade and it is not free: the demo is no longer
|
| 13 |
+
self-contained, which is the local app's whole point. The Space page says so.
|
| 14 |
+
Nothing about `engine.py` or the local Apple Silicon path changes.
|
| 15 |
+
|
| 16 |
+
**The tool schemas go through the model's own chat template locally, and the
|
| 17 |
+
result is handed over as an ordinary system message** -- never as a `tools=`
|
| 18 |
+
argument. That matters: a provider given `tools=` applies its own template and
|
| 19 |
+
returns structured `tool_calls` objects, which the agent does not speak. Doing it
|
| 20 |
+
this way, the model emits `<tool_call>` as ordinary text and `toolcall.parse` and
|
| 21 |
+
`_StreamGate` see the same input they see locally.
|
| 22 |
+
|
| 23 |
+
Every provider serving Qwen3 offers `conversational` only, not raw
|
| 24 |
+
text-generation, so `render()` returns a *message list* rather than a prompt
|
| 25 |
+
string. The agent treats that value as opaque -- it renders and passes it
|
| 26 |
+
straight to `stream()` -- so nothing downstream cares which it is.
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
from __future__ import annotations
|
| 30 |
+
|
| 31 |
+
import os
|
| 32 |
+
import time
|
| 33 |
+
from typing import Any, Generator, Iterable, Sequence
|
| 34 |
+
|
| 35 |
+
from controlai_agent.engine import Chunk, SamplingConfig, Stats
|
| 36 |
+
|
| 37 |
+
DEFAULT_API_MODEL = os.environ.get("CONTROLAI_MODEL_API", "Qwen/Qwen3-14B")
|
| 38 |
+
# "auto" lets the Hub pick whichever provider currently serves the model.
|
| 39 |
+
DEFAULT_PROVIDER = os.environ.get("CONTROLAI_PROVIDER", "auto")
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class RemoteEngine:
|
| 43 |
+
"""Streaming generation against a hosted model, over huggingface_hub."""
|
| 44 |
+
|
| 45 |
+
def __init__(
|
| 46 |
+
self,
|
| 47 |
+
model_id: str = DEFAULT_API_MODEL,
|
| 48 |
+
adapter_path: str | None = None,
|
| 49 |
+
sampling: SamplingConfig | None = None,
|
| 50 |
+
provider: str = DEFAULT_PROVIDER,
|
| 51 |
+
token: str | None = None,
|
| 52 |
+
) -> None:
|
| 53 |
+
from huggingface_hub import InferenceClient
|
| 54 |
+
from transformers import AutoTokenizer
|
| 55 |
+
|
| 56 |
+
if adapter_path:
|
| 57 |
+
raise ValueError(
|
| 58 |
+
"RemoteEngine cannot load a LoRA adapter: the weights are not local. "
|
| 59 |
+
"Serve a merged model, or use LocalEngine."
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
self.model_id = model_id
|
| 63 |
+
self.adapter_path = None
|
| 64 |
+
self.sampling = sampling or SamplingConfig()
|
| 65 |
+
self.provider = provider
|
| 66 |
+
|
| 67 |
+
t0 = time.time()
|
| 68 |
+
# Tokenizer only -- no weights. This is what renders the chat template and
|
| 69 |
+
# counts tokens for history truncation, so it must be the served model's.
|
| 70 |
+
self.tokenizer = AutoTokenizer.from_pretrained(model_id)
|
| 71 |
+
self.client = InferenceClient(
|
| 72 |
+
model=model_id,
|
| 73 |
+
provider=provider,
|
| 74 |
+
token=token or os.environ.get("HF_TOKEN"),
|
| 75 |
+
timeout=300,
|
| 76 |
+
)
|
| 77 |
+
self.load_seconds = time.time() - t0
|
| 78 |
+
self.last_stats = Stats()
|
| 79 |
+
self.supports_thinking = self._probe_thinking_support()
|
| 80 |
+
print(f"[remote] {model_id} via provider={provider} ({self.load_seconds:.1f}s)")
|
| 81 |
+
|
| 82 |
+
# ------------------------------------------------------------------ setup
|
| 83 |
+
|
| 84 |
+
def _probe_thinking_support(self) -> bool:
|
| 85 |
+
try:
|
| 86 |
+
self.tokenizer.apply_chat_template(
|
| 87 |
+
[{"role": "user", "content": "x"}],
|
| 88 |
+
tokenize=False,
|
| 89 |
+
add_generation_prompt=True,
|
| 90 |
+
enable_thinking=False,
|
| 91 |
+
)
|
| 92 |
+
return True
|
| 93 |
+
except (TypeError, ValueError):
|
| 94 |
+
return False
|
| 95 |
+
|
| 96 |
+
def render(
|
| 97 |
+
self,
|
| 98 |
+
messages: Sequence[dict[str, Any]],
|
| 99 |
+
tools: Sequence[dict[str, Any]] | None = None,
|
| 100 |
+
enable_thinking: bool = False,
|
| 101 |
+
) -> list[dict[str, Any]]:
|
| 102 |
+
"""Messages ready for a chat-completions call, tool block folded in.
|
| 103 |
+
|
| 104 |
+
The tool block is not hand-written: the model's own template renders it,
|
| 105 |
+
and it is lifted back out of the rendered system turn. That keeps the
|
| 106 |
+
wording identical to the local path even if the template changes.
|
| 107 |
+
"""
|
| 108 |
+
out = [dict(m) for m in messages]
|
| 109 |
+
if tools:
|
| 110 |
+
rendered = self.tokenizer.apply_chat_template(
|
| 111 |
+
list(messages), tools=list(tools), tokenize=False, add_generation_prompt=True
|
| 112 |
+
)
|
| 113 |
+
head, tail = "<|im_start|>system\n", "<|im_end|>"
|
| 114 |
+
start = rendered.find(head)
|
| 115 |
+
end = rendered.find(tail, start) if start >= 0 else -1
|
| 116 |
+
if start >= 0 and end > start:
|
| 117 |
+
system = rendered[start + len(head):end]
|
| 118 |
+
out = [m for m in out if m.get("role") != "system"]
|
| 119 |
+
out.insert(0, {"role": "system", "content": system})
|
| 120 |
+
|
| 121 |
+
if not enable_thinking and self.supports_thinking:
|
| 122 |
+
# Qwen3's documented soft switch. `chat_template_kwargs` would be the
|
| 123 |
+
# direct equivalent, but it is an `extra_body` passthrough that not
|
| 124 |
+
# every provider forwards; this is in the prompt and always arrives.
|
| 125 |
+
for m in reversed(out):
|
| 126 |
+
if m.get("role") == "user":
|
| 127 |
+
m["content"] = f"{m['content']} /no_think"
|
| 128 |
+
break
|
| 129 |
+
return out
|
| 130 |
+
|
| 131 |
+
def encode(self, text: str) -> list[int]:
|
| 132 |
+
return self.tokenizer.encode(text)
|
| 133 |
+
|
| 134 |
+
def count_tokens(self, text: str) -> int:
|
| 135 |
+
return len(self.tokenizer.encode(text))
|
| 136 |
+
|
| 137 |
+
# ------------------------------------------------------------------ cache
|
| 138 |
+
|
| 139 |
+
def reset_cache(self) -> None:
|
| 140 |
+
"""No-op. The KV cache lives on the provider's side, out of reach."""
|
| 141 |
+
|
| 142 |
+
def prewarm(self, text: str) -> int:
|
| 143 |
+
"""No-op beyond reporting size.
|
| 144 |
+
|
| 145 |
+
`LocalEngine.prewarm` prefills the shared prefix into a persistent cache.
|
| 146 |
+
There is no cache to prefill here, and the provider's own prefix caching
|
| 147 |
+
is not ours to manage, so this only reports what the prefix costs.
|
| 148 |
+
"""
|
| 149 |
+
return self.count_tokens(text)
|
| 150 |
+
|
| 151 |
+
# ------------------------------------------------------------- generation
|
| 152 |
+
|
| 153 |
+
def _request(self, messages: list[dict[str, Any]], cfg: SamplingConfig,
|
| 154 |
+
stop: list[str], budget: int):
|
| 155 |
+
return self.client.chat_completion(
|
| 156 |
+
messages=messages,
|
| 157 |
+
stream=True,
|
| 158 |
+
max_tokens=max(budget, 1),
|
| 159 |
+
temperature=cfg.temperature if cfg.temperature > 0 else None,
|
| 160 |
+
top_p=cfg.top_p if 0 < cfg.top_p < 1 else None,
|
| 161 |
+
# Natively supported here, so this matches engine.py exactly rather
|
| 162 |
+
# than approximating it -- and a flat repetition_penalty stays out,
|
| 163 |
+
# for the reason engine.py gives.
|
| 164 |
+
presence_penalty=cfg.presence_penalty or None,
|
| 165 |
+
stop=stop or None,
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
def stream(
|
| 169 |
+
self,
|
| 170 |
+
prompt: str | list[int],
|
| 171 |
+
sampling: SamplingConfig | None = None,
|
| 172 |
+
stop: Iterable[str] = (),
|
| 173 |
+
think_budget: int | None = None,
|
| 174 |
+
) -> Generator[Chunk, None, None]:
|
| 175 |
+
"""Yield output chunks as they arrive. See `LocalEngine.stream`."""
|
| 176 |
+
cfg = sampling or self.sampling
|
| 177 |
+
convo = prompt if isinstance(prompt, list) and prompt and isinstance(prompt[0], dict) else [
|
| 178 |
+
{"role": "user", "content": prompt if isinstance(prompt, str) else self.tokenizer.decode(prompt)}
|
| 179 |
+
]
|
| 180 |
+
stops = [s for s in stop if s]
|
| 181 |
+
|
| 182 |
+
t0 = time.time()
|
| 183 |
+
self.last_stats = Stats(
|
| 184 |
+
prompt_tokens=sum(self.count_tokens(str(m.get("content", ""))) for m in convo)
|
| 185 |
+
)
|
| 186 |
+
first_token_at: float | None = None
|
| 187 |
+
|
| 188 |
+
emitted = 0
|
| 189 |
+
thinking = False
|
| 190 |
+
think_tokens = 0
|
| 191 |
+
seen = ""
|
| 192 |
+
# Runs at most twice: once normally, and again if the reasoning budget
|
| 193 |
+
# was overrun. `LocalEngine` closes an overrunning <think> block by
|
| 194 |
+
# injecting the closing token mid-stream; there is no mid-stream here, so
|
| 195 |
+
# the equivalent is to stop, hand back what was generated with `</think>`
|
| 196 |
+
# appended, and let a continuation produce the answer. Same transcript,
|
| 197 |
+
# one extra round trip.
|
| 198 |
+
for attempt in (0, 1):
|
| 199 |
+
overran = False
|
| 200 |
+
for event in self._request(convo, cfg, stops, cfg.max_tokens - emitted):
|
| 201 |
+
try:
|
| 202 |
+
piece = event.choices[0].delta.content
|
| 203 |
+
except (AttributeError, IndexError, TypeError):
|
| 204 |
+
piece = None
|
| 205 |
+
if not piece:
|
| 206 |
+
continue
|
| 207 |
+
if first_token_at is None:
|
| 208 |
+
first_token_at = time.time()
|
| 209 |
+
self.last_stats.prefill_seconds = first_token_at - t0
|
| 210 |
+
|
| 211 |
+
seen += piece
|
| 212 |
+
if "<think>" in piece:
|
| 213 |
+
thinking, think_tokens = True, 0
|
| 214 |
+
elif "</think>" in piece:
|
| 215 |
+
thinking = False
|
| 216 |
+
elif thinking:
|
| 217 |
+
think_tokens += 1
|
| 218 |
+
|
| 219 |
+
emitted += 1
|
| 220 |
+
yield Chunk(
|
| 221 |
+
text=piece,
|
| 222 |
+
token=-1, # the API returns text, not ids
|
| 223 |
+
thinking=thinking,
|
| 224 |
+
tool_call="<tool_call>" in piece,
|
| 225 |
+
)
|
| 226 |
+
|
| 227 |
+
if thinking and think_budget and think_tokens >= think_budget:
|
| 228 |
+
overran = True
|
| 229 |
+
break
|
| 230 |
+
if emitted >= cfg.max_tokens:
|
| 231 |
+
break
|
| 232 |
+
|
| 233 |
+
if not (overran and attempt == 0):
|
| 234 |
+
break
|
| 235 |
+
yield Chunk(text="</think>", token=-1, thinking=False)
|
| 236 |
+
convo = convo + [{"role": "assistant", "content": seen + "</think>"}]
|
| 237 |
+
thinking = False
|
| 238 |
+
|
| 239 |
+
self.last_stats.generated_tokens = emitted
|
| 240 |
+
self.last_stats.decode_seconds = time.time() - (first_token_at or t0)
|
| 241 |
+
|
| 242 |
+
def generate(
|
| 243 |
+
self,
|
| 244 |
+
prompt: str | list[int],
|
| 245 |
+
sampling: SamplingConfig | None = None,
|
| 246 |
+
stop: Iterable[str] = (),
|
| 247 |
+
think_budget: int | None = None,
|
| 248 |
+
) -> str:
|
| 249 |
+
return "".join(c.text for c in self.stream(prompt, sampling, stop, think_budget))
|
|
@@ -44,7 +44,12 @@ class Embedder:
|
|
| 44 |
# retrieval on the Space looks over- or under-eager, MIN_COSINE is the
|
| 45 |
# knob (CONTROLAI_MIN_COSINE), not this.
|
| 46 |
self._backend = (backend or os.environ.get("CONTROLAI_BACKEND", "mlx")).lower()
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
self._backend = "mlx"
|
| 49 |
self.model_id = model_id or (TORCH_MODEL_ID if self._backend == "torch" else MODEL_ID)
|
| 50 |
self._model = None
|
|
|
|
| 44 |
# retrieval on the Space looks over- or under-eager, MIN_COSINE is the
|
| 45 |
# knob (CONTROLAI_MIN_COSINE), not this.
|
| 46 |
self._backend = (backend or os.environ.get("CONTROLAI_BACKEND", "mlx")).lower()
|
| 47 |
+
# Anything that is not MLX embeds through transformers. On the Space that
|
| 48 |
+
# is CPU torch, which is ample for one query at a time; "api" refers to
|
| 49 |
+
# where *generation* happens, and says nothing about the embedder.
|
| 50 |
+
if self._backend in ("torch", "pytorch", "cuda", "api", "remote", "hosted"):
|
| 51 |
+
self._backend = "torch"
|
| 52 |
+
else:
|
| 53 |
self._backend = "mlx"
|
| 54 |
self.model_id = model_id or (TORCH_MODEL_ID if self._backend == "torch" else MODEL_ID)
|
| 55 |
self._model = None
|
|
@@ -1,39 +1,38 @@
|
|
| 1 |
-
#
|
| 2 |
#
|
| 3 |
-
# The Space runs on
|
| 4 |
-
#
|
| 5 |
-
#
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
-
#
|
| 8 |
-
#
|
| 9 |
-
|
| 10 |
-
# did exactly that, because gradio 6.x needs huggingface-hub >=1.16 and every
|
| 11 |
-
# transformers <4.56 needs <1.0.
|
| 12 |
-
# Left unconstrained deliberately. Pinning <2.9 to force CUDA 12 wheels was
|
| 13 |
-
# tried, on the theory that torch 2.11's CUDA 13 wheels were behind ZeroGPU's
|
| 14 |
-
# "No CUDA GPUs are available". It was not, and the pin was strictly worse:
|
| 15 |
-
# unpinned, the log reaches "Waiting for a GPU" then "Successfully acquired a
|
| 16 |
-
# GPU" before the worker dies (14.2s); pinned, neither line appears at all and
|
| 17 |
-
# it fails in 1.8s, never reaching GPU acquisition. Do not retry that bound.
|
| 18 |
torch>=2.4.0
|
| 19 |
-
transformers>=4.51.0
|
| 20 |
-
accelerate>=0.30.0
|
| 21 |
-
bitsandbytes>=0.43.0 # 4-bit NF4; CUDA-only
|
| 22 |
-
sentencepiece>=0.2.0
|
| 23 |
|
| 24 |
-
#
|
| 25 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
-
#
|
| 28 |
numpy>=1.24.0
|
| 29 |
scipy>=1.11.0
|
| 30 |
control>=0.9.4
|
| 31 |
cvxpy>=1.4.0
|
| 32 |
matplotlib>=3.7.0
|
| 33 |
jsonschema>=4.20.0
|
|
|
|
|
|
|
| 34 |
rank-bm25>=0.2.2
|
| 35 |
pypdf>=3.17.0
|
| 36 |
pymupdf>=1.23.0
|
|
|
|
|
|
|
| 37 |
fastapi>=0.110.0
|
| 38 |
uvicorn>=0.28.0
|
| 39 |
python-multipart>=0.0.9
|
|
|
|
| 1 |
+
# Hugging Face Space dependencies -- see app_space.py.
|
| 2 |
#
|
| 3 |
+
# The Space runs on plain CPU hardware. Token generation is remote (RemoteEngine
|
| 4 |
+
# -> Inference Providers), so there is no CUDA and no local LLM here. torch is
|
| 5 |
+
# still needed, on CPU, for the 0.6B retrieval embedder: one query is a few
|
| 6 |
+
# hundred milliseconds, which is fine.
|
| 7 |
+
#
|
| 8 |
+
# Do NOT install this locally: requirements.txt is the Apple Silicon MLX runtime.
|
| 9 |
|
| 10 |
+
# CPU wheels only. The default PyPI torch wheel drags several GB of CUDA
|
| 11 |
+
# libraries that would never be used here.
|
| 12 |
+
--extra-index-url https://download.pytorch.org/whl/cpu
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
torch>=2.4.0
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
+
# No upper bound: the platform appends its own gradio/spaces/uvicorn on top of
|
| 16 |
+
# this file, and one extra constraint can make the resolve impossible. Pinning
|
| 17 |
+
# transformers<4.56 did exactly that once -- gradio 6 needs huggingface-hub>=1.16
|
| 18 |
+
# and every transformers<4.56 needs <1.0.
|
| 19 |
+
transformers>=4.51.0
|
| 20 |
+
huggingface-hub>=0.34.0 # InferenceClient.chat_completion
|
| 21 |
|
| 22 |
+
# Deterministic numerics -- every number in an answer comes from these, locally.
|
| 23 |
numpy>=1.24.0
|
| 24 |
scipy>=1.11.0
|
| 25 |
control>=0.9.4
|
| 26 |
cvxpy>=1.4.0
|
| 27 |
matplotlib>=3.7.0
|
| 28 |
jsonschema>=4.20.0
|
| 29 |
+
|
| 30 |
+
# Retrieval over the local control-engineering library.
|
| 31 |
rank-bm25>=0.2.2
|
| 32 |
pypdf>=3.17.0
|
| 33 |
pymupdf>=1.23.0
|
| 34 |
+
|
| 35 |
+
# Web console.
|
| 36 |
fastapi>=0.110.0
|
| 37 |
uvicorn>=0.28.0
|
| 38 |
python-multipart>=0.0.9
|