airayven7 commited on
Commit
08f584d
·
verified ·
1 Parent(s): ecee177

Sync from GitHub 0fe9a08

Browse files
app.py CHANGED
@@ -122,6 +122,10 @@ def _build_libraries():
122
  VISUAL_STORE, PARSED_STORE, PIPELINE = _build_libraries()
123
  # Valid agent-brain keys, for validating the per-request `agent_model`.
124
  _AGENT_MODEL_KEYS = {m["key"] for m in AGENT_MODELS}
 
 
 
 
125
  # Valid search-index keys, for validating the per-request `retrieval_mode`.
126
  _RETRIEVAL_MODE_KEYS = {m["key"] for m in RETRIEVAL_MODES}
127
  # method -> store, for the picker / pdf lookups. In mock both keys map to the
@@ -312,6 +316,10 @@ def api_find(
312
  agent_model = DEFAULT_AGENT_MODEL
313
  if retrieval_mode not in _RETRIEVAL_MODE_KEYS:
314
  retrieval_mode = DEFAULT_RETRIEVAL_MODE
 
 
 
 
315
  log.info(
316
  "find: manual=%s k=%s think=%s model=%s search=%s viewer=%s hist=%d q=%r",
317
  manual, k, bool(think), agent_model, retrieval_mode, viewer,
@@ -369,7 +377,11 @@ def index():
369
  # so the settings dropdown needs no extra round-trip on load.
370
  .replace(
371
  "__AGENT_MODELS_JSON__",
372
- json.dumps([{"key": m["key"], "label": m["label"]} for m in AGENT_MODELS]),
 
 
 
 
373
  )
374
  .replace("__AGENT_MODEL__", DEFAULT_AGENT_MODEL)
375
  # Search-index picker: which index the search tool ranks against
 
122
  VISUAL_STORE, PARSED_STORE, PIPELINE = _build_libraries()
123
  # Valid agent-brain keys, for validating the per-request `agent_model`.
124
  _AGENT_MODEL_KEYS = {m["key"] for m in AGENT_MODELS}
125
+ # Brains too large to keep the ColEmbed visual retriever resident alongside them
126
+ # (constants.AGENT_MODELS `forbid_visual`). While one is active, a "visual"
127
+ # retrieval_mode is forced to "parsed" so ColEmbed never loads next to it (OOM).
128
+ _NO_VISUAL_MODEL_KEYS = {m["key"] for m in AGENT_MODELS if m.get("forbid_visual")}
129
  # Valid search-index keys, for validating the per-request `retrieval_mode`.
130
  _RETRIEVAL_MODE_KEYS = {m["key"] for m in RETRIEVAL_MODES}
131
  # method -> store, for the picker / pdf lookups. In mock both keys map to the
 
316
  agent_model = DEFAULT_AGENT_MODEL
317
  if retrieval_mode not in _RETRIEVAL_MODE_KEYS:
318
  retrieval_mode = DEFAULT_RETRIEVAL_MODE
319
+ # The big brains can't share VRAM with the ColEmbed visual retriever — force
320
+ # parsed so ColEmbed never loads alongside them (the UI also greys it out).
321
+ if retrieval_mode == "visual" and agent_model in _NO_VISUAL_MODEL_KEYS:
322
+ retrieval_mode = DEFAULT_RETRIEVAL_MODE
323
  log.info(
324
  "find: manual=%s k=%s think=%s model=%s search=%s viewer=%s hist=%d q=%r",
325
  manual, k, bool(think), agent_model, retrieval_mode, viewer,
 
377
  # so the settings dropdown needs no extra round-trip on load.
378
  .replace(
379
  "__AGENT_MODELS_JSON__",
380
+ json.dumps([
381
+ {"key": m["key"], "label": m["label"],
382
+ "forbidVisual": bool(m.get("forbid_visual"))}
383
+ for m in AGENT_MODELS
384
+ ]),
385
  )
386
  .replace("__AGENT_MODEL__", DEFAULT_AGENT_MODEL)
387
  # Search-index picker: which index the search tool ranks against
core/constants.py CHANGED
@@ -45,44 +45,45 @@ MINICPM_AGENT_REVISION = os.environ.get("MINICPM_AGENT_REVISION", "") or None
45
 
46
  # Selectable agent brains, offered in the UI settings panel. ONE model is meant
47
  # to be resident in VRAM at a time — switching evicts the previous and loads the
48
- # next (models/minicpm_agent.use_model). NOTE: the FIRST/default brain is loaded at
49
- # import (ZeroGPU emulation phase) and materialized into the forked GPU worker,
50
- # where empty_cache() can't reclaim it it stays stuck for the process. A brain
51
- # SWITCHED IN at runtime must fit the headroom ABOVE the resident set
52
- # (VLM+ColEmbed+embedder) + that stuck default (~15 GiB free on the 48 GiB `large`
53
- # slice — see core/vram.py).
 
 
 
 
 
 
 
 
 
54
  #
55
- # The default is MiniCPM4.1-8B at int8 (~8.5 GiB via bitsandbytes). As the LONE
56
- # stuck brain it REPLACES the old 1B default (it does not stack on it), so the
57
- # resident set + one ~8.5 GiB brain still leaves room for the grounding spike — the
58
- # same footprint class as MiniCPM3-4B, which was VRAM-vetted to fit. (bf16 8B at
59
- # ~16 GiB still does NOT fit; int8 is what makes the 8B deployable as the default.)
60
- # Caveat: with an ~8.5 GiB default already stuck, switching ANOTHER 4-8 GiB brain
61
- # in at runtime for a UI A/B is tight and may OOM at grounding — the default path
62
- # is fine.
63
  # Each loads as an AutoModelForCausalLM; `trust_remote_code` (default False) flags
64
  # the ones that ship custom modeling code (MiniCPM3 / MiniCPM4.1). `thinking` flags
65
  # whether the chat template accepts enable_thinking (Qwen3, MiniCPM5, MiniCPM4.1 do
66
- # — tool routing passes it False; MiniCPM3 does not). int8/4bit brains need
67
- # bitsandbytes (requirements.txt). The FIRST entry is the default at boot; the
68
- # minicpm5-1b entry stays selectable and still tracks the MINICPM_AGENT_MODEL_ID/
69
- # REVISION env overrides. Only one brain is resident at a time.
70
  AGENT_MODELS = [
71
  {
72
- "key": "minicpm4.1-8b-8bit",
73
- "label": "MiniCPM4.1 8B (8-bit)",
74
  "model_id": "openbmb/MiniCPM4.1-8B",
 
 
 
 
 
 
75
  "revision": None,
76
- # DEFAULT brain. Hybrid-reasoning 8B at int8 (~8.5 GiB) — the best
77
- # deployable eval config (0.85 tool / 0.91 args; with the v3 prompt it also
78
- # recovers half the coincidence fix). trust_remote_code custom modeling
79
- # (sparse "InfLLM v2" attention); runs clean on current transformers,
80
- # unlike MiniCPM3-4B. Quantized weights load straight onto the GPU, so
81
- # device_map is set (skips the host→device .to copy).
82
  "thinking": True,
83
  "trust_remote_code": True,
84
- "quantization": "8bit",
85
- "device_map": {"": 0},
 
86
  },
87
  {
88
  "key": "minicpm5-1b",
@@ -132,35 +133,6 @@ AGENT_MODELS = [
132
  # switch time with ~5 GiB to spare after the grounding spike. The 8B (16
133
  # GiB) didn't fit — see core/vram.py / the find-turn VRAM logs.
134
  },
135
- {
136
- "key": "minicpm4.1-8b",
137
- "label": "MiniCPM4.1 8B",
138
- "model_id": "openbmb/MiniCPM4.1-8B",
139
- "revision": None,
140
- # Hybrid-reasoning 8B (enable_thinking supported; routing passes it False).
141
- # trust_remote_code custom modeling (sparse "InfLLM v2" attention) — same
142
- # rot risk that broke MiniCPM3-4B on current transformers; pin a reviewed
143
- # commit for any real use. NOTE: 8B / ~16 GiB bf16 does NOT fit the
144
- # production find turn (the 8B didn't fit — VRAM logs); benchmarkable in
145
- # THIS eval (brain-only load) to gauge the quality ceiling, but shipping it
146
- # needs quantization (4-bit ~5-6 GiB) + a coexistence VRAM check.
147
- "thinking": True,
148
- "trust_remote_code": True,
149
- },
150
- {
151
- "key": "minicpm4.1-8b-4bit",
152
- "label": "MiniCPM4.1 8B (4-bit)",
153
- "model_id": "openbmb/MiniCPM4.1-8B",
154
- "revision": None,
155
- "thinking": True,
156
- "trust_remote_code": True,
157
- # bitsandbytes nf4 (~5-6 GiB vs ~16 GiB bf16) to fit the 8B into the
158
- # find-turn VRAM budget; quantized weights load straight onto the GPU, so
159
- # device_map is set (skips the host→device .to copy). The question this
160
- # answers: does 4-bit hold the bf16 8B's quality (0.87/0.86)?
161
- "quantization": "4bit",
162
- "device_map": {"": 0},
163
- },
164
  ]
165
  DEFAULT_AGENT_MODEL = AGENT_MODELS[0]["key"]
166
  # A tool-call decision is short JSON; a rerank reply is a single number. 96 was
 
45
 
46
  # Selectable agent brains, offered in the UI settings panel. ONE model is meant
47
  # to be resident in VRAM at a time — switching evicts the previous and loads the
48
+ # next (models/minicpm_agent.use_model). The FIRST/default brain is loaded at
49
+ # import (ZeroGPU's startup phase) and "packed" into the forked GPU worker, so it
50
+ # stays resident for the whole process and the common (no-switch) turn pays NO
51
+ # per-turn load cost. A brain SWITCHED IN at runtime is built inside the GPU
52
+ # window instead (not packed), so its first turn after a switch is slower.
53
+ #
54
+ # The default is MiniCPM4.1-8B in bf16 (~16 GiB). It fits because the ColEmbed
55
+ # visual retriever (~8 GiB) is NO LONGER packed at import — it lazy-loads only
56
+ # when the "visual" search index is used (models/colembed.py). So the resident set
57
+ # is the MiniCPM-V "eyes" + the Nemotron text embedder + this 8B brain, which
58
+ # leaves room for the grounding spike on the 48 GiB slice. Because the 8B and
59
+ # ColEmbed cannot BOTH be resident, the 8B sets forbid_visual: a turn that asks for
60
+ # visual search while it is active is served by the parsed index instead (enforced
61
+ # in app.py). Smaller brains leave room for ColEmbed to lazy-load, so they keep
62
+ # visual search.
63
  #
 
 
 
 
 
 
 
 
64
  # Each loads as an AutoModelForCausalLM; `trust_remote_code` (default False) flags
65
  # the ones that ship custom modeling code (MiniCPM3 / MiniCPM4.1). `thinking` flags
66
  # whether the chat template accepts enable_thinking (Qwen3, MiniCPM5, MiniCPM4.1 do
67
+ # — tool routing passes it False; MiniCPM3 does not). The FIRST entry is the
68
+ # default at boot; the minicpm5-1b entry stays selectable and still tracks the
69
+ # MINICPM_AGENT_MODEL_ID/REVISION env overrides. Only one brain is resident at a time.
 
70
  AGENT_MODELS = [
71
  {
72
+ "key": "minicpm4.1-8b",
73
+ "label": "MiniCPM4.1 8B",
74
  "model_id": "openbmb/MiniCPM4.1-8B",
75
+ # DEFAULT brain. Hybrid-reasoning 8B in bf16 (~16 GiB) — the best eval
76
+ # config (0.90 tool / 0.90 args, and it unlocks the v3 coincidence fix).
77
+ # Loaded at import so ZeroGPU packs it (no per-turn reload) and it decodes
78
+ # in bf16 (faster than a bnb-int8 build). trust_remote_code custom modeling
79
+ # (sparse "InfLLM v2" attention); runs clean on current transformers, unlike
80
+ # MiniCPM3-4B — pin a reviewed commit (revision) before a real deploy.
81
  "revision": None,
 
 
 
 
 
 
82
  "thinking": True,
83
  "trust_remote_code": True,
84
+ # Too large to keep the ColEmbed visual retriever resident alongside it, so
85
+ # visual search is disabled while this brain is active (falls back to parsed).
86
+ "forbid_visual": True,
87
  },
88
  {
89
  "key": "minicpm5-1b",
 
133
  # switch time with ~5 GiB to spare after the grounding spike. The 8B (16
134
  # GiB) didn't fit — see core/vram.py / the find-turn VRAM logs.
135
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
  ]
137
  DEFAULT_AGENT_MODEL = AGENT_MODELS[0]["key"]
138
  # A tool-call decision is short JSON; a rerank reply is a single number. 96 was
frontend/index.html CHANGED
@@ -355,19 +355,23 @@
355
  <p class="mt-1 text-xs text-brand-400">The model that finds pages and points. Switching loads it fresh — the first turn after a change is slower.</p>
356
  </div>
357
 
358
- <!-- search index: which index the search tool ranks the query against -->
359
- <div class="mt-5">
 
 
 
360
  <label class="block text-xs font-semibold uppercase tracking-wide text-brand-500/80 mb-1.5">Search index</label>
361
  <div class="relative">
362
  <select x-model="retrievalMode"
363
  class="w-full appearance-none rounded-xl border border-brand-200 bg-brand-50/50 px-3.5 py-2.5 pr-9 text-sm font-medium text-navy focus:border-brand-400 focus:ring-2 focus:ring-brand-100 outline-none transition">
364
  <template x-for="m in retrievalModes" :key="m.key">
365
- <option :value="m.key" x-text="m.label"></option>
 
366
  </template>
367
  </select>
368
  <i data-lucide="chevron-down" class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-brand-400"></i>
369
  </div>
370
- <p class="mt-1 text-xs text-brand-400">What the search tool ranks against. Parsed (text) wins on specs and tables; Visual (ColEmbed) on diagrams.</p>
371
  </div>
372
 
373
  <!-- think: let the VLM reason before committing to a circle box -->
 
355
  <p class="mt-1 text-xs text-brand-400">The model that finds pages and points. Switching loads it fresh — the first turn after a change is slower.</p>
356
  </div>
357
 
358
+ <!-- search index: which index the search tool ranks the query against. The
359
+ big brains (forbidVisual) can't keep ColEmbed resident, so Visual is
360
+ disabled and force-reset to Parsed while one of them is the agent. -->
361
+ <div class="mt-5"
362
+ x-effect="if((agentModels.find(x=>x.key===agentModel)||{}).forbidVisual && retrievalMode==='visual') retrievalMode='parsed'">
363
  <label class="block text-xs font-semibold uppercase tracking-wide text-brand-500/80 mb-1.5">Search index</label>
364
  <div class="relative">
365
  <select x-model="retrievalMode"
366
  class="w-full appearance-none rounded-xl border border-brand-200 bg-brand-50/50 px-3.5 py-2.5 pr-9 text-sm font-medium text-navy focus:border-brand-400 focus:ring-2 focus:ring-brand-100 outline-none transition">
367
  <template x-for="m in retrievalModes" :key="m.key">
368
+ <option :value="m.key" x-text="m.label"
369
+ :disabled="m.key==='visual' && (agentModels.find(x=>x.key===agentModel)||{}).forbidVisual"></option>
370
  </template>
371
  </select>
372
  <i data-lucide="chevron-down" class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-brand-400"></i>
373
  </div>
374
+ <p class="mt-1 text-xs text-brand-400">What the search tool ranks against. Parsed (text) wins on specs and tables; Visual (ColEmbed) on diagrams.<span x-show="(agentModels.find(x=>x.key===agentModel)||{}).forbidVisual" class="text-amber-600"> Visual is unavailable with the current brain (too large to load ColEmbed alongside it).</span></p>
375
  </div>
376
 
377
  <!-- think: let the VLM reason before committing to a circle box -->
models/colembed.py CHANGED
@@ -1,12 +1,15 @@
1
  """Nemotron ColEmbed v2: late-interaction page embeddings + MaxSim retrieval.
2
 
3
- The model is a module-level global: ZeroGPU packs module-level CUDA tensors at
4
- startup and shares them with the GPU worker, whereas function arguments are
5
- pickled and the trust_remote_code model class is not picklable.
 
 
 
6
 
7
  _embed_pages_on_gpu is a ZeroGPU entry point (used at index time);
8
  maxsim_search is a plain function so the ask pipeline can run it inside its
9
- own single GPU call together with answer generation.
10
 
11
  forward_images/forward_queries return zero-padded [batch, tokens, dim] tensors
12
  with real tokens L2-normalized, so padding rows are exactly zero. We strip them
@@ -33,45 +36,56 @@ from core.constants import (
33
  )
34
  from core.vram import log_vram
35
 
36
- _MODEL = (
37
- AutoModel.from_pretrained(
38
- COLEMBED_MODEL_ID,
39
- revision=COLEMBED_REVISION,
40
- trust_remote_code=True,
41
- dtype=torch.bfloat16,
42
- attn_implementation=COLEMBED_ATTN,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  )
44
- .to("cuda")
45
- .eval()
46
- )
47
- # Pre-build the processor the remote code would otherwise lazily create per
48
- # GPU worker (it caches on this exact attribute, see _get_processor).
49
- _MODEL._processor = AutoProcessor.from_pretrained(
50
- COLEMBED_MODEL_ID, revision=COLEMBED_REVISION, trust_remote_code=True
51
- )
52
- log_vram("load-colembed")
53
-
54
- # The remote code's forward_documents hardcodes DataLoader(num_workers=8), but
55
- # the ZeroGPU worker is a daemonic process and may not spawn children
56
- # ("daemonic processes are not allowed to have children"). Patch the DataLoader
57
- # name in the model's own module to force in-process loading. A subclass (not a
58
- # wrapper function) because the remote code also uses the name in isinstance().
59
- _remote_module = sys.modules[type(_MODEL).__module__]
60
-
61
-
62
- class _SingleProcessDataLoader(_remote_module.DataLoader):
63
- def __init__(self, *args, **kwargs):
64
- kwargs["num_workers"] = 0
65
- super().__init__(*args, **kwargs)
66
 
 
 
 
 
67
 
68
- _remote_module.DataLoader = _SingleProcessDataLoader
 
 
 
69
 
70
 
71
  @spaces.GPU(duration=EMBED_GPU_DURATION)
72
  def _embed_pages_on_gpu(images: list[Image.Image]) -> list[np.ndarray]:
 
73
  with torch.no_grad():
74
- embs = _MODEL.forward_images(images, batch_size=EMBED_BATCH_SIZE)
75
  out = []
76
  for emb in embs: # [tokens, dim]; zero rows are padding
77
  mask = emb.abs().sum(dim=-1) > 0
@@ -85,8 +99,9 @@ def maxsim_search(
85
  """Top-K (doc_id, page_num, score) across docs. Must run on GPU (called
86
  from within a @spaces.GPU context)."""
87
  results = []
 
88
  with torch.no_grad():
89
- q = _MODEL.forward_queries([question], batch_size=1)[0].to(torch.float16)
90
  for refs, batch in store.iter_page_batches(doc_ids, SCORE_PAGES_PER_BATCH):
91
  emb = torch.from_numpy(batch).to(q.device) # [B, T, D] float16
92
  sim = torch.einsum("qd,btd->bqt", q, emb).float()
 
1
  """Nemotron ColEmbed v2: late-interaction page embeddings + MaxSim retrieval.
2
 
3
+ The model is a module-level global, but unlike the other models — it is NOT
4
+ built at import. It lazy-loads on first use (_load) so ZeroGPU does NOT pack it
5
+ at startup, freeing ~8 GiB for the bf16 8B agent brain to stay resident. The
6
+ trade: the FIRST visual-search call after a cold worker builds it inside the GPU
7
+ window (not packed). The default search index is "parsed" (no ColEmbed) and the
8
+ 8B brain forbids visual search, so the production default path never loads it.
9
 
10
  _embed_pages_on_gpu is a ZeroGPU entry point (used at index time);
11
  maxsim_search is a plain function so the ask pipeline can run it inside its
12
+ own single GPU call together with answer generation. Both call _load() first.
13
 
14
  forward_images/forward_queries return zero-padded [batch, tokens, dim] tensors
15
  with real tokens L2-normalized, so padding rows are exactly zero. We strip them
 
36
  )
37
  from core.vram import log_vram
38
 
39
+ _MODEL = None
40
+
41
+
42
+ def _load():
43
+ """Build ColEmbed and cache it as the module global, on first use. Deliberately
44
+ NOT called at import: keeping it off the GPU at startup means ZeroGPU does not
45
+ pack it, freeing ~8 GiB so the bf16 8B agent brain fits the resident set. Must
46
+ run on GPU (called from within a @spaces.GPU context). A no-op once loaded."""
47
+ global _MODEL
48
+ if _MODEL is not None:
49
+ return _MODEL
50
+ model = (
51
+ AutoModel.from_pretrained(
52
+ COLEMBED_MODEL_ID,
53
+ revision=COLEMBED_REVISION,
54
+ trust_remote_code=True,
55
+ dtype=torch.bfloat16,
56
+ attn_implementation=COLEMBED_ATTN,
57
+ )
58
+ .to("cuda")
59
+ .eval()
60
  )
61
+ # Pre-build the processor the remote code would otherwise lazily create per
62
+ # GPU worker (it caches on this exact attribute, see _get_processor).
63
+ model._processor = AutoProcessor.from_pretrained(
64
+ COLEMBED_MODEL_ID, revision=COLEMBED_REVISION, trust_remote_code=True
65
+ )
66
+ # The remote code's forward_documents hardcodes DataLoader(num_workers=8), but
67
+ # the ZeroGPU worker is a daemonic process and may not spawn children
68
+ # ("daemonic processes are not allowed to have children"). Patch the DataLoader
69
+ # name in the model's own module to force in-process loading. A subclass (not a
70
+ # wrapper function) because the remote code also uses the name in isinstance().
71
+ remote_module = sys.modules[type(model).__module__]
 
 
 
 
 
 
 
 
 
 
 
72
 
73
+ class _SingleProcessDataLoader(remote_module.DataLoader):
74
+ def __init__(self, *args, **kwargs):
75
+ kwargs["num_workers"] = 0
76
+ super().__init__(*args, **kwargs)
77
 
78
+ remote_module.DataLoader = _SingleProcessDataLoader
79
+ _MODEL = model
80
+ log_vram("load-colembed")
81
+ return _MODEL
82
 
83
 
84
  @spaces.GPU(duration=EMBED_GPU_DURATION)
85
  def _embed_pages_on_gpu(images: list[Image.Image]) -> list[np.ndarray]:
86
+ model = _load()
87
  with torch.no_grad():
88
+ embs = model.forward_images(images, batch_size=EMBED_BATCH_SIZE)
89
  out = []
90
  for emb in embs: # [tokens, dim]; zero rows are padding
91
  mask = emb.abs().sum(dim=-1) > 0
 
99
  """Top-K (doc_id, page_num, score) across docs. Must run on GPU (called
100
  from within a @spaces.GPU context)."""
101
  results = []
102
+ model = _load()
103
  with torch.no_grad():
104
+ q = model.forward_queries([question], batch_size=1)[0].to(torch.float16)
105
  for refs, batch in store.iter_page_batches(doc_ids, SCORE_PAGES_PER_BATCH):
106
  emb = torch.from_numpy(batch).to(q.device) # [B, T, D] float16
107
  sim = torch.einsum("qd,btd->bqt", q, emb).float()
models/minicpm_agent.py CHANGED
@@ -328,25 +328,6 @@ def use_model(key: str | None = None) -> str:
328
  device_map = spec.get("device_map")
329
  if device_map is not None:
330
  load_kwargs["device_map"] = device_map
331
- # A spec can request on-the-fly bitsandbytes quantization (e.g. "4bit" to fit
332
- # an 8B brain into the find-turn VRAM budget). Quantized weights are placed on
333
- # the GPU at load time, so such a spec must also set device_map (skips the
334
- # .to("cuda") below). bitsandbytes is imported lazily so non-quantized brains
335
- # — and the production image, which omits it — never touch it.
336
- quant = spec.get("quantization")
337
- if quant in ("4bit", "8bit"):
338
- from transformers import BitsAndBytesConfig
339
-
340
- load_kwargs["quantization_config"] = (
341
- BitsAndBytesConfig(
342
- load_in_4bit=True,
343
- bnb_4bit_quant_type="nf4",
344
- bnb_4bit_compute_dtype=torch.bfloat16,
345
- bnb_4bit_use_double_quant=True,
346
- )
347
- if quant == "4bit"
348
- else BitsAndBytesConfig(load_in_8bit=True)
349
- )
350
  model = AutoModelForCausalLM.from_pretrained(spec["model_id"], **load_kwargs)
351
  if device_map is None:
352
  model = model.to("cuda")
@@ -357,17 +338,11 @@ def use_model(key: str | None = None) -> str:
357
 
358
 
359
  # Load the default brain eagerly at import so ZeroGPU's startup tensor-packing
360
- # covers it and the common (no-switch) first turn pays no load cost. EXCEPTION: a
361
- # bitsandbytes-quantized default must NOT be built in the main process. Plain
362
- # .to("cuda") models are safe at import because the `spaces` library patches torch
363
- # and "packs" them into the forked GPU worker but bitsandbytes initializes CUDA
364
- # directly (bypassing that patch), which hard-errors on ZeroGPU ("CUDA must not be
365
- # initialized in the main process") and crashes the Space at boot. So for a
366
- # quantized default, DEFER the load to first GPU use: the pipeline calls use_model()
367
- # inside its @spaces.GPU window (pipelines/agent_ask.py), and _generate() lazy-loads
368
- # as a backstop — both on the GPU, the only supported place to build a bnb model.
369
- if not _spec(DEFAULT_AGENT_MODEL).get("quantization"):
370
- use_model(DEFAULT_AGENT_MODEL)
371
 
372
 
373
  def _template_kwargs() -> dict:
@@ -383,10 +358,9 @@ def _generate(
383
  """Greedy decode the assistant's next message. Traced as one `generation`
384
  (the resident brain as the model, the messages as input, the reply and the
385
  in/out token counts attached) when Langfuse is configured."""
386
- # Backstop for a deferred (quantized) default brain: it isn't loaded at import
387
- # on ZeroGPU, so build it on first use. Always reached inside a @spaces.GPU
388
- # window (the pipeline's find turn / the eval's GPU fn) the supported place to
389
- # instantiate a bitsandbytes model. A no-op once a brain is resident.
390
  if _MODEL is None:
391
  use_model(DEFAULT_AGENT_MODEL)
392
  with tracing.generation(
 
328
  device_map = spec.get("device_map")
329
  if device_map is not None:
330
  load_kwargs["device_map"] = device_map
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
331
  model = AutoModelForCausalLM.from_pretrained(spec["model_id"], **load_kwargs)
332
  if device_map is None:
333
  model = model.to("cuda")
 
338
 
339
 
340
  # Load the default brain eagerly at import so ZeroGPU's startup tensor-packing
341
+ # covers it and the common (no-switch) turn pays no per-turn load cost. A plain
342
+ # .to("cuda") model is safe at import on ZeroGPU: the `spaces` library patches
343
+ # torch and "packs" it into the forked GPU worker (the bf16 8B default included),
344
+ # so it stays resident without ever being rebuilt per turn.
345
+ use_model(DEFAULT_AGENT_MODEL)
 
 
 
 
 
 
346
 
347
 
348
  def _template_kwargs() -> dict:
 
358
  """Greedy decode the assistant's next message. Traced as one `generation`
359
  (the resident brain as the model, the messages as input, the reply and the
360
  in/out token counts attached) when Langfuse is configured."""
361
+ # Defensive: ensure a brain is resident. The default loads at import, so this
362
+ # is a no-op in normal operation; it only fires if that eager load was skipped
363
+ # (e.g. a future deferred default). Always reached inside a @spaces.GPU window.
 
364
  if _MODEL is None:
365
  use_model(DEFAULT_AGENT_MODEL)
366
  with tracing.generation(
requirements.txt CHANGED
@@ -2,7 +2,6 @@ spaces
2
  gradio
3
  transformers>=4.57.2,<5
4
  accelerate
5
- bitsandbytes # int8/4bit agent-brain quantization (default brain is int8 MiniCPM4.1-8B)
6
  torchvision
7
  pymupdf
8
  pillow
 
2
  gradio
3
  transformers>=4.57.2,<5
4
  accelerate
 
5
  torchvision
6
  pymupdf
7
  pillow