atakan Claude Opus 5 commited on
Commit
74544ce
·
1 Parent(s): 098a4cf

add: Hosted generation for the Space, and retire the ZeroGPU attempt

Browse files

The 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 CHANGED
@@ -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`, `engine_torch.py`, `requirements-space.txt`)
183
- The demo Space (huggingface.co/spaces/atakankahya/ControlAI-Agent) runs Linux/NVIDIA on ZeroGPU,
184
- where MLX does not exist. `CONTROLAI_BACKEND` makes `app.py::_make_engine` build
185
- `TorchEngine` instead of `LocalEngine`; `Embedder` switches on the same variable. That is the whole
186
- switch two branches, no orchestrator. It accepts `torch`, `pytorch` or `cuda`, and **raises on
187
- anything it does not recognise rather than falling back to MLX**. The deployed Space still carries
188
- `CONTROLAI_BACKEND=pytorch` as a variable from the old orchestrator; a check for `"torch"` alone
189
- silently selected MLX on a box with no MLX, and the failure surfaced as `ModuleNotFoundError:
190
- mlx_lm` several frames from the real cause.
191
-
192
- `engine_torch.py` mirrors `engine.py` rather than calling `model.generate`, because `generate`
193
- cannot express either of the two things that matter: `DynamicCache.crop()` for prefix reuse across
194
- tool steps, and injecting `</think>` to close an overrunning reasoning block.
195
-
196
- **It loads bf16 with an explicit `.to("cuda")`, and `app_space.py` constructs it at module import
197
- scope. Do not reintroduce `device_map` or bitsandbytes, and do not move the construction into a
198
- lifespan or a request.** All of those fail identically with `RuntimeError: Low-level CUDA init
199
- (torch._C._cuda_init) reached`. ZeroGPU patches torch during the import of the Space's entry module
200
- and attaches real hardware only inside a `@spaces.GPU` call; only CUDA operations inside that import
201
- window are intercepted. `app.py` normally builds the model in FastAPI's `lifespan`, on a
202
- `ThreadPoolExecutor` worker after import, on another thread and `.to("cuda")` there reaches real
203
- CUDA init and raises. `app_space.py::_build_agent_at_import` builds the agent during import and
204
- assigns `app._agent`, so `lifespan`'s `get_agent()` is a no-op. **The retrieval embedder is a second
205
- model with the same problem** it loads lazily on the first query, which is a request, outside the
206
- window so the same function embeds one throwaway string to force it onto the GPU during import.
207
- Without that, the Space starts fine and every answer carries `[agent] retrieval failed`. `device_map` fails independently, by
208
- routing transformers through `caching_allocator_warmup` and its direct
209
- `torch.empty(..., device="cuda")`; bitsandbytes requires `device_map`, so **4-bit quantisation is
210
- unavailable**, which is what forces a model small enough to carry in bf16: `Qwen/Qwen3-8B`, ~16GB
211
- against ~28GB for the 14B run locally.
212
-
213
- **All inference must run inside a `@spaces.GPU` call.** Loading the model in the import window is
214
- necessary but not sufficient: ZeroGPU creates it under CUDA *emulation*, reports `cuda:0`, and packs
215
- its tensors only a `@spaces.GPU` call materialises them on real hardware. Forward passes anywhere
216
- else do not fail. They read unmaterialised tensors and return fluent-looking multilingual noise at
217
- roughly fifteen minutes a turn, which is the worst possible failure mode: a demo that is up,
218
- responsive, and confidently wrong. `app.py` exposes a `stream_hook`, `None` locally; `app_space.py`
219
- sets it to `_gpu_stream`, a `@spaces.GPU(duration=300)` generator wrapping one whole agent turn.
220
- Wrapping the *turn* rather than each `engine.stream()` call is deliberate a turn is several
221
- generations sharing one KV cache, and splitting them across separate calls would put that shared
222
- state on the far side of a process boundary each time. `_collect()` exists so `/api/chat` honours the
223
- hook too; `ControlAgent.run()` consumes `self.stream` directly and would bypass it.
224
-
225
- **The dedicated inference thread must never carry a CUDA call.** `spaces` intercepts CUDA only
226
- inside the context it manages, and `app.py`'s `ThreadPoolExecutor` is outside it a `@spaces.GPU`
227
- call made from there fails in its own worker with `RuntimeError: No CUDA GPUs are available`, even
228
- with a GPU genuinely attached (`hardware.current: zero-a10g`). `USE_INFERENCE_THREAD` gates the
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
app.py CHANGED
@@ -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
 
app_space.py CHANGED
@@ -1,90 +1,43 @@
1
- """Hugging Face Space entry point: the same FastAPI app, on ZeroGPU.
2
-
3
- Not used for local runs -- `./run.sh` runs `app.py` directly. This module exists
4
- because the Space's platform imposes requirements that have nothing to do with
5
- ControlAI, and each of the four below was learned by having the Space fail:
6
-
7
- 1. A `@spaces.GPU`-decorated function is only recognised by the platform's
8
- startup validation if it is wired to an actual Gradio event handler. A
9
- function called only from a FastAPI route fails startup with "No @spaces.GPU
10
- function detected". Hence the hidden probe button.
11
- 2. That function must be a module-level `def`. Nested inside a `with
12
- gr.Blocks():` block, the same detection fails.
13
- 3. The probe must be `launch()`ed, on its own port, and **not** merely mounted.
14
- Mounting it with `gr.mount_gradio_app` was tried: the Space fails to start
15
- with "No @spaces.GPU function detected during startup", so mounting does not
16
- register the handler the way the platform's validation looks for. Do not
17
- mount it into the FastAPI app and *also* run uvicorn on the same port,
18
- though -- that is two servers and collides with "address already in use".
19
- Launch with `ssr_mode=False`: Gradio 6 defaults to SSR, which spawns a Node
20
- subprocess into the very process ZeroGPU then `fork()`s its CUDA worker
21
- from, and a fork parent holding a subprocess is a plausible cause of the
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
- # Assigned, not setdefault: this module is the CUDA entry point by definition,
40
- # and a stale Space variable must not be able to select something else. One did
41
- # -- CONTROLAI_BACKEND=pytorch, left over from the old orchestrator -- and the
42
- # Space booted into the MLX engine and died on `import mlx_lm`.
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 into data/rag_index/ before the agent loads."""
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
- port = int(os.environ.get("PORT", 7860))
223
- # Launched, not mounted: mounting fails startup validation ("No @spaces.GPU
224
- # function detected"). Separate port, non-blocking, and ssr_mode=False so no
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__":
controlai_agent/engine_api.py ADDED
@@ -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))
controlai_rag/embeddings.py CHANGED
@@ -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
- if self._backend != "torch":
 
 
 
 
 
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
requirements-space.txt CHANGED
@@ -1,39 +1,38 @@
1
- # Dependencies for the Hugging Face Space only -- see app_space.py.
2
  #
3
- # The Space runs on Linux/NVIDIA, where MLX does not exist, so inference goes
4
- # through controlai_agent/engine_torch.py instead. Do NOT install this file
5
- # locally: requirements.txt is the Apple Silicon runtime and is MLX-only.
 
 
 
6
 
7
- # Deliberately unpinned above the floor. The Space platform force-installs its
8
- # own gradio, spaces, uvicorn and a torch ceiling on top of this file, and any
9
- # upper bound here can make the resolve impossible -- pinning transformers <4.56
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
- # gradio, spaces and huggingface-hub are injected by the platform at build
25
- # time; listing them here only adds constraints that can conflict with it.
 
 
 
 
26
 
27
- # Everything below is shared with requirements.txt (minus mlx-lm).
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