teszenofficial commited on
Commit
4be37e4
·
verified ·
1 Parent(s): 740c69d

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +115 -84
app.py CHANGED
@@ -7,7 +7,9 @@ Qué hace
7
  - `milo_core.py` debe estar junto a este app.py en el Space (el export del Colab 3 lo incluye). Si no está,
8
  se descarga del repo del modelo.
9
  - Usa la plantilla de chat con tokens especiales (<|system|> <|user|> <|assistant|> <|end|>), la MISMA del
10
- fine-tuning, y el motor de generación de milo_core (KV-cache, min-p, no-repeat-ngram, etc.).
 
 
11
  - Endpoints: /chat, /chat/stream (SSE), /web_chat (búsqueda web + respuesta), /health, /v1/model_info,
12
  /web_search_debug. La UI de Gradio queda en "/".
13
 
@@ -49,7 +51,11 @@ MAX_NEW_TOKENS_HARD_CAP = int(os.environ.get("MILO_MAX_NEW_TOKENS_CAP", "400"))
49
  NUM_THREADS = int(os.environ.get("MILO_NUM_THREADS", str(os.cpu_count() or 2)))
50
  USE_CONTEXT = os.environ.get("MILO_USE_CONTEXT", "0") == "1" # 1 = usa el historial de la conversación
51
  NUM_CANDIDATES = int(os.environ.get("MILO_NUM_CANDIDATES", "1")) # >1 = best-of-N (en lote), N veces más costoso
52
- SYSTEM_PROMPT_ENV = os.environ.get("MILO_SYSTEM_PROMPT") # "none" para desactivar; si no, el de config.json
 
 
 
 
53
 
54
  TAVILY_API_KEY = os.environ.get("TAVILY_API_KEY", "").strip()
55
  SEARCH_TIMEOUT = float(os.environ.get("MILO_SEARCH_TIMEOUT", "8"))
@@ -77,6 +83,7 @@ _blocked: Optional[torch.Tensor] = None
77
  _load_error: Optional[str] = None
78
  _tok_info: dict = {}
79
  _stage: Optional[str] = None
 
80
  _GEN_LOCK = threading.Lock() # una generación a la vez: en CPU, varias en paralelo solo se estorban
81
 
82
 
@@ -84,7 +91,7 @@ _GEN_LOCK = threading.Lock() # una generación a la vez: en CPU, varias en par
84
  # Carga del modelo
85
  # ==========================================================================================
86
  def load_model():
87
- global _model, _tok, _cfg, _system, _blocked, _load_error, _tok_info, _stage
88
  print(f"[Milo X1] Descargando modelo desde {MODEL_REPO_ID} (revision={MODEL_REVISION}) ...")
89
  try:
90
  from safetensors.torch import load_file
@@ -130,11 +137,12 @@ def load_model():
130
  _system = cfg_json.get("system_prompt", mc.DEFAULT_SYSTEM)
131
  _blocked = mc.make_blocked_ids(tok, cfg.vocab_size, "cpu", allow=tok.stop_ids)
132
  _model, _tok, _cfg, _stage = model, tok, cfg, cfg_json.get("stage")
 
133
  _tok_info = {"vocab_size": tok.vocab_size, "unicode_roundtrip": mc.check_tokenizer_unicode(tok, verbose=False)}
134
  _load_error = None
135
  print(f"[Milo X1] Modelo cargado (etapa: {_stage}). {model.num_params()/1e6:.1f}M parámetros | d_model={cfg.d_model} "
136
  f"| capas={cfg.n_layers} | heads={cfg.n_heads} (kv={cfg.n_kv_heads}) | contexto={cfg.context_length} "
137
- f"| vocab={tok.vocab_size} | unicode/emoji={'OK' if _tok_info['unicode_roundtrip'] else 'CON PÉRDIDAS'}")
138
 
139
 
140
  # ==========================================================================================
@@ -185,31 +193,35 @@ def make_gen(params: dict, seed=None) -> mc.GenConfig:
185
  max_new_tokens=int(params["max_new_tokens"]), temperature=float(params["temperature"]),
186
  top_k=int(params["top_k"] or 0), top_p=float(params["top_p"] if params["top_p"] else 1.0),
187
  min_p=float(params.get("min_p", 0.0)), repetition_penalty=float(params["repetition_penalty"]),
188
- no_repeat_ngram=int(params.get("no_repeat_ngram", 0)), seed=seed)
189
 
190
 
191
- def iter_text(ids: List[int], gen: mc.GenConfig, cancel: Optional[threading.Event] = None):
192
- """Generador SÍNCRONO de fragmentos de texto en vivo. La generación corre en un hilo; si el consumidor
193
- deja de leer (cliente desconectado), se cancela al siguiente token."""
194
  cancel = cancel or threading.Event()
195
  q: "queue.Queue" = queue.Queue()
196
- dec = IncrementalDecoder(_tok)
197
 
198
- def on_token(t):
199
- if cancel.is_set():
200
- raise _Cancel()
201
- d = dec.push(t)
202
- if d:
203
- q.put(d)
 
 
204
 
205
  def work():
206
  try:
207
  with _GEN_LOCK:
208
  if not cancel.is_set():
209
- mc.generate(_model, [ids], gen, stop_ids=_tok.stop_ids, blocked_ids=_blocked, on_token=on_token)
210
- tail = dec.flush()
211
- if tail:
212
- q.put(tail)
 
 
213
  except _Cancel:
214
  pass
215
  except Exception as e: # noqa
@@ -243,54 +255,51 @@ def score_candidate(text: str) -> float:
243
  return (len(set(bigrams)) / max(len(bigrams), 1)) * 0.5 + (len(set(words)) / len(words)) * 0.3 + min(len(words), 40) / 40 * 0.2
244
 
245
 
246
- def generate_text(ids: List[int], gen: mc.GenConfig, n: int = 1) -> str:
247
- """Respuesta completa. Con n>1 genera n candidatas EN LOTE (una sola llamada) y elige la más coherente."""
248
- n = max(1, n)
249
  with _GEN_LOCK:
250
- outs = mc.generate(_model, [ids] * n, gen, stop_ids=_tok.stop_ids, blocked_ids=_blocked)
251
- texts = [_tok.decode(o).strip() for o in outs]
252
- return max(texts, key=score_candidate) if n > 1 else texts[0]
 
 
 
 
253
 
254
 
255
  # ==========================================================================================
256
- # Armado del prompt (plantilla de chat con tokens especiales)
257
  # ==========================================================================================
258
- def _budget(max_new: int) -> int:
259
  ctx = _cfg.context_length
260
- return ctx - min(max_new, ctx // 2) # mismo cálculo que mc.generate
 
 
 
261
 
262
 
263
- def build_ids(history: List[dict], user_text: str, max_new: int) -> List[int]:
264
  """Si no cabe: descarta turnos viejos y luego recorta el INICIO del mensaje (se conserva el final)."""
265
- budget = _budget(max_new)
266
- hist, text = list(history), user_text
267
- if len(text) > budget * 16:
268
- text = text[-budget * 16:]
269
- ids: List[int] = []
270
- for _ in range(80):
271
- ids = _tok.encode_chat(hist + [{"role": "user", "content": text}], system=_system, add_generation_prompt=True)
272
- if len(ids) <= budget:
273
- return ids
274
- if hist:
275
- hist = hist[2:]
276
- continue
277
- if len(text) <= 1:
278
- break
279
- text = text[-max(1, int(len(text) * 0.85)):]
280
- return ids[:1] + ids[-(budget - 1):]
281
 
282
 
283
- def build_search_ids(question: str, entrada: str, max_new: int) -> List[int]:
284
  """Pregunta + resultados web en un solo mensaje de usuario (igual que el SFT fusiona instruction + input).
285
  Si no cabe, se recortan los RESULTADOS, nunca la pregunta."""
286
- budget = _budget(max_new)
287
- ent, ids = entrada, []
288
  for _ in range(60):
289
- ids = _tok.encode_chat([{"role": "user", "content": f"{question}\n\n{ent}"}], system=_system, add_generation_prompt=True)
290
- if len(ids) <= budget:
291
- return ids
292
  ent = ent[: int(len(ent) * 0.9)].rstrip()
293
- return ids[:1] + ids[-(budget - 1):]
294
 
295
 
296
  _LEGACY_RE = re.compile(r"###\s*Instrucci[oó]n:\s*\n(.*?)(?:\n\s*###\s*Entrada:\s*\n(.*?))?\s*\n\s*###\s*Respuesta:\s*$", re.S)
@@ -520,10 +529,6 @@ def rate_allow(key: str, limit: int = SEARCH_RATE_PER_MIN, window: float = 60.0)
520
 
521
 
522
 
523
- def build_search_user_text(question: str, entrada: str) -> str:
524
- return f"{question}\n\n{entrada}"
525
-
526
-
527
  # ==========================================================================================
528
  # API FastAPI
529
  # ==========================================================================================
@@ -548,6 +553,9 @@ class ChatRequest(BaseModel):
548
  repetition_penalty: float = Field(CHAT_DEFAULTS["repetition_penalty"], ge=1.0, le=2.0)
549
  no_repeat_ngram: int = Field(0, ge=0, le=8)
550
  seed: Optional[int] = None
 
 
 
551
 
552
 
553
  def _history_msgs(messages: List[ChatMessage]) -> Tuple[List[dict], str]:
@@ -566,10 +574,13 @@ def _history_msgs(messages: List[ChatMessage]) -> Tuple[List[dict], str]:
566
 
567
 
568
  def _resolve_request(req: ChatRequest):
569
- """Devuelve (ids, GenConfig, search_mode)."""
 
 
 
 
570
  legacy = parse_legacy_prompt(req.prompt) or (
571
  parse_legacy_prompt(req.messages[-1].content) if (not req.prompt and req.messages and req.messages[-1].role == "user") else None)
572
- given = req.model_fields_set
573
  if legacy is not None and legacy[1]: # con "Entrada" = modo búsqueda web
574
  question, entrada = legacy
575
  p = dict(SEARCH_DEFAULTS)
@@ -577,7 +588,7 @@ def _resolve_request(req: ChatRequest):
577
  if k in given:
578
  p[k] = getattr(req, k)
579
  p["max_new_tokens"] = min(p["max_new_tokens"], MAX_NEW_TOKENS_HARD_CAP)
580
- return build_search_ids(question, entrada, p["max_new_tokens"]), make_gen(p, req.seed), True
581
  if legacy is not None:
582
  history, user_text = [], legacy[0]
583
  else:
@@ -586,7 +597,7 @@ def _resolve_request(req: ChatRequest):
586
  history, user_text = _history_msgs(req.messages)
587
  p = dict(max_new_tokens=req.max_new_tokens, temperature=req.temperature, top_k=req.top_k, top_p=req.top_p,
588
  min_p=req.min_p, repetition_penalty=req.repetition_penalty, no_repeat_ngram=req.no_repeat_ngram)
589
- return build_ids(history, user_text, req.max_new_tokens), make_gen(p, req.seed), False
590
 
591
 
592
  @app.on_event("startup")
@@ -597,7 +608,8 @@ def _startup():
597
  @app.get("/health")
598
  def health():
599
  return {"status": "ok" if _model is not None else "model_not_loaded", "model_repo": MODEL_REPO_ID,
600
- "quantized": QUANTIZE, "error": _load_error, "stage": _stage,
 
601
  "web_search": {"tavily_key_set": bool(TAVILY_API_KEY), "fallback": "wikipedia"},
602
  "tokenizer": _tok_info or None}
603
 
@@ -610,6 +622,7 @@ def model_info():
610
  "d_model": _cfg.d_model, "n_layers": _cfg.n_layers, "n_heads": _cfg.n_heads, "n_kv_heads": _cfg.n_kv_heads,
611
  "context_length": _cfg.context_length, "vocab_size": _cfg.vocab_size, "quantized": QUANTIZE,
612
  "stage": _stage, "use_context": USE_CONTEXT, "num_candidates": NUM_CANDIDATES,
 
613
  "system_prompt": _system, "tokenizer": _tok_info}
614
 
615
 
@@ -617,37 +630,47 @@ def model_info():
617
  def chat(req: ChatRequest):
618
  if _model is None:
619
  raise HTTPException(503, _load_error or "Modelo no cargado.")
620
- ids, gen, search = _resolve_request(req)
621
  t0 = time.time()
622
- response = generate_text(ids, gen, n=1 if search else NUM_CANDIDATES)
623
- return {"response": response, "elapsed_seconds": round(time.time() - t0, 2),
624
- "mode": "web_search" if search else "chat"}
 
 
625
 
626
 
627
  @app.post("/chat/stream")
628
  async def chat_stream(req: ChatRequest, request: Request):
629
  if _model is None:
630
  raise HTTPException(503, _load_error or "Modelo no cargado.")
631
- ids, gen, search = _resolve_request(req)
632
 
633
  async def event_stream():
634
  loop = asyncio.get_running_loop()
635
  cancel = threading.Event()
636
  try:
637
- if search or NUM_CANDIDATES <= 1:
638
- it = iter_text(ids, gen, cancel)
639
  sentinel = object()
 
640
  while True:
641
- piece = await loop.run_in_executor(None, next, it, sentinel)
642
- if piece is sentinel:
643
  break
644
- yield f"data: {json.dumps({'delta': piece}, ensure_ascii=False)}\n\n"
 
 
 
 
 
 
 
645
  if await request.is_disconnected():
646
  break
647
  else:
648
- best = await loop.run_in_executor(None, generate_text, ids, gen, NUM_CANDIDATES)
649
- for i in range(0, len(best), 4):
650
- yield f"data: {json.dumps({'delta': best[i:i + 4]}, ensure_ascii=False)}\n\n"
651
  await asyncio.sleep(0.01)
652
  except Exception as e: # noqa
653
  yield f"data: {json.dumps({'error': str(e)}, ensure_ascii=False)}\n\n"
@@ -688,11 +711,12 @@ async def web_chat(req: WebChatRequest, request: Request):
688
  items = pick_results(raw, dedupe_domains=(provider != "wikipedia"))
689
  entrada, items = build_entrada(items)
690
  max_new = min(SEARCH_DEFAULTS["max_new_tokens"], MAX_NEW_TOKENS_HARD_CAP)
691
- ids = build_search_ids(question, entrada, max_new)
 
692
  gen = make_gen(dict(SEARCH_DEFAULTS, max_new_tokens=max_new))
693
  t0 = time.time()
694
  try:
695
- reply = await loop.run_in_executor(None, generate_text, ids, gen, 1)
696
  except Exception as e: # noqa
697
  print(f"[Milo X1] web_chat: generación falló: {e}")
698
  return {"fallback": True, "notice": "No pude generar la respuesta con la búsqueda web ahora", "debug": str(e)[:300]}
@@ -744,20 +768,27 @@ def gradio_respond(message: str, history):
744
  yield _load_error or "Modelo no cargado. Revisa MODEL_REPO_ID del Space."
745
  return
746
  hist = _history_to_msgs(history)[-8:] if USE_CONTEXT else []
747
- ids = build_ids(hist, message, CHAT_DEFAULTS["max_new_tokens"])
748
  gen = make_gen(CHAT_DEFAULTS)
749
- if NUM_CANDIDATES > 1:
750
- best = generate_text(ids, gen, n=NUM_CANDIDATES)
751
  for i in range(0, len(best), 4):
752
  yield best[: i + 4]
753
  if not best.strip():
754
  yield "(el modelo no generó texto — prueba otra pregunta o revisa el checkpoint)"
755
  return
756
- partial = ""
757
- for delta in iter_text(ids, gen):
758
- partial += delta
759
- yield partial
760
- if not partial.strip():
 
 
 
 
 
 
 
761
  yield "(el modelo no generó texto — prueba otra pregunta o revisa el checkpoint)"
762
 
763
 
 
7
  - `milo_core.py` debe estar junto a este app.py en el Space (el export del Colab 3 lo incluye). Si no está,
8
  se descarga del repo del modelo.
9
  - Usa la plantilla de chat con tokens especiales (<|system|> <|user|> <|assistant|> <|end|>), la MISMA del
10
+ fine-tuning, y el motor de generación de milo_core (KV-cache, min-p, decodificación especulativa, etc.).
11
+ - Si el modelo se entrenó con razonamiento (config.json: "thinking": true), PRIMERO piensa (<think>...</think>) y
12
+ DESPUÉS responde. El razonamiento no se muestra salvo que se pida (show_thinking) o MILO_SHOW_THINKING=1.
13
  - Endpoints: /chat, /chat/stream (SSE), /web_chat (búsqueda web + respuesta), /health, /v1/model_info,
14
  /web_search_debug. La UI de Gradio queda en "/".
15
 
 
51
  NUM_THREADS = int(os.environ.get("MILO_NUM_THREADS", str(os.cpu_count() or 2)))
52
  USE_CONTEXT = os.environ.get("MILO_USE_CONTEXT", "0") == "1" # 1 = usa el historial de la conversación
53
  NUM_CANDIDATES = int(os.environ.get("MILO_NUM_CANDIDATES", "1")) # >1 = best-of-N (en lote), N veces más costoso
54
+ SYSTEM_PROMPT_ENV = os.environ.get("MILO_SYSTEM_PROMPT")
55
+ THINK_DEFAULT = os.environ.get("MILO_THINK", "1") == "1" # 1 = piensa antes de responder (si el modelo lo soporta)
56
+ MAX_THINK_TOKENS = int(os.environ.get("MILO_MAX_THINK", "200"))
57
+ SHOW_THINKING = os.environ.get("MILO_SHOW_THINKING", "0") == "1"
58
+ SPEC_TOKENS = int(os.environ.get("MILO_SPEC", "4")) # 0 = apaga la decodificación especulativa # "none" para desactivar; si no, el de config.json
59
 
60
  TAVILY_API_KEY = os.environ.get("TAVILY_API_KEY", "").strip()
61
  SEARCH_TIMEOUT = float(os.environ.get("MILO_SEARCH_TIMEOUT", "8"))
 
83
  _load_error: Optional[str] = None
84
  _tok_info: dict = {}
85
  _stage: Optional[str] = None
86
+ _tf: bool = False # el modelo usa bloque <think>
87
  _GEN_LOCK = threading.Lock() # una generación a la vez: en CPU, varias en paralelo solo se estorban
88
 
89
 
 
91
  # Carga del modelo
92
  # ==========================================================================================
93
  def load_model():
94
+ global _model, _tok, _cfg, _system, _blocked, _load_error, _tok_info, _stage, _tf
95
  print(f"[Milo X1] Descargando modelo desde {MODEL_REPO_ID} (revision={MODEL_REVISION}) ...")
96
  try:
97
  from safetensors.torch import load_file
 
137
  _system = cfg_json.get("system_prompt", mc.DEFAULT_SYSTEM)
138
  _blocked = mc.make_blocked_ids(tok, cfg.vocab_size, "cpu", allow=tok.stop_ids)
139
  _model, _tok, _cfg, _stage = model, tok, cfg, cfg_json.get("stage")
140
+ _tf = bool(cfg_json.get("thinking", False))
141
  _tok_info = {"vocab_size": tok.vocab_size, "unicode_roundtrip": mc.check_tokenizer_unicode(tok, verbose=False)}
142
  _load_error = None
143
  print(f"[Milo X1] Modelo cargado (etapa: {_stage}). {model.num_params()/1e6:.1f}M parámetros | d_model={cfg.d_model} "
144
  f"| capas={cfg.n_layers} | heads={cfg.n_heads} (kv={cfg.n_kv_heads}) | contexto={cfg.context_length} "
145
+ f"| pensamiento={'sí' if _tf else 'no'} | vocab={tok.vocab_size} | unicode/emoji={'OK' if _tok_info['unicode_roundtrip'] else 'CON PÉRDIDAS'}")
146
 
147
 
148
  # ==========================================================================================
 
193
  max_new_tokens=int(params["max_new_tokens"]), temperature=float(params["temperature"]),
194
  top_k=int(params["top_k"] or 0), top_p=float(params["top_p"] if params["top_p"] else 1.0),
195
  min_p=float(params.get("min_p", 0.0)), repetition_penalty=float(params["repetition_penalty"]),
196
+ no_repeat_ngram=int(params.get("no_repeat_ngram", 0)), speculative=SPEC_TOKENS, seed=seed)
197
 
198
 
199
+ def iter_events(msgs: List[dict], gen: mc.GenConfig, think: bool, max_think: int, cancel: Optional[threading.Event] = None):
200
+ """Generador SÍNCRONO de eventos en vivo: ("think", texto) mientras razona y ("answer", texto) al responder.
201
+ La generación corre en un hilo; si el consumidor deja de leer (cliente desconectado) se cancela al siguiente token."""
202
  cancel = cancel or threading.Event()
203
  q: "queue.Queue" = queue.Queue()
204
+ d_think, d_ans = IncrementalDecoder(_tok), IncrementalDecoder(_tok)
205
 
206
+ def cb(kind, dec):
207
+ def on_token(t):
208
+ if cancel.is_set():
209
+ raise _Cancel()
210
+ d = dec.push(t)
211
+ if d:
212
+ q.put((kind, d))
213
+ return on_token
214
 
215
  def work():
216
  try:
217
  with _GEN_LOCK:
218
  if not cancel.is_set():
219
+ mc.chat_generate(_model, _tok, msgs, system=_system, gen=gen, thinking_format=_tf, think=think,
220
+ max_think=max_think, on_think=cb("think", d_think), on_answer=cb("answer", d_ans))
221
+ for kind, dec in (("think", d_think), ("answer", d_ans)):
222
+ tail = dec.flush()
223
+ if tail:
224
+ q.put((kind, tail))
225
  except _Cancel:
226
  pass
227
  except Exception as e: # noqa
 
255
  return (len(set(bigrams)) / max(len(bigrams), 1)) * 0.5 + (len(set(words)) / len(words)) * 0.3 + min(len(words), 40) / 40 * 0.2
256
 
257
 
258
+ def run_chat(msgs: List[dict], gen: mc.GenConfig, think: bool, max_think: int, n: int = 1) -> mc.ChatResult:
259
+ """Respuesta completa. Con n>1 y SIN razonamiento, genera n candidatas EN LOTE y elige la más coherente."""
260
+ active = _tf and think
261
  with _GEN_LOCK:
262
+ if n > 1 and not active:
263
+ ids = _tok.encode_chat(msgs, system=_system, add_generation_prompt=True, thinking=_tf, think_prefix="closed")
264
+ blocked = mc.make_blocked_ids(_tok, _cfg.vocab_size, "cpu", allow=_tok.stop_ids)
265
+ outs = mc.generate(_model, [ids] * n, gen, stop_ids=_tok.stop_ids, blocked_ids=blocked)
266
+ texts = [_tok.decode(o).strip() for o in outs]
267
+ return mc.ChatResult("", max(texts, key=score_candidate))
268
+ return mc.chat_generate(_model, _tok, msgs, system=_system, gen=gen, thinking_format=_tf, think=think, max_think=max_think)
269
 
270
 
271
  # ==========================================================================================
272
+ # Armado de mensajes (se ajustan al contexto; la plantilla de chat la aplica milo_core)
273
  # ==========================================================================================
274
+ def _reserve(max_new: int, think: bool, max_think: int) -> int:
275
  ctx = _cfg.context_length
276
+ r = min(max_new, ctx // 2)
277
+ if _tf and think:
278
+ r += min(max_think, ctx // 2 - 8)
279
+ return r
280
 
281
 
282
+ def fit_msgs(history: List[dict], user_text: str, max_new: int, think: bool, max_think: int) -> List[dict]:
283
  """Si no cabe: descarta turnos viejos y luego recorta el INICIO del mensaje (se conserva el final)."""
284
+ sess = mc.ChatSession(_model, _tok, system=_system, gen=mc.GenConfig(max_new_tokens=max_new),
285
+ thinking_format=_tf, think=think, max_think=max_think)
286
+ sess.history = list(history)
287
+ if len(user_text) > 16000:
288
+ user_text = user_text[-16000:]
289
+ return sess._fit(user_text, think)
 
 
 
 
 
 
 
 
 
 
290
 
291
 
292
+ def fit_search_msgs(question: str, entrada: str, max_new: int, think: bool, max_think: int) -> List[dict]:
293
  """Pregunta + resultados web en un solo mensaje de usuario (igual que el SFT fusiona instruction + input).
294
  Si no cabe, se recortan los RESULTADOS, nunca la pregunta."""
295
+ budget = max(32, _cfg.context_length - _reserve(max_new, think, max_think) - 4)
296
+ ent = entrada
297
  for _ in range(60):
298
+ msgs = [{"role": "user", "content": f"{question}\n\n{ent}"}]
299
+ if len(_tok.encode_chat(msgs, system=_system, thinking=_tf, think_prefix="open")) <= budget:
300
+ return msgs
301
  ent = ent[: int(len(ent) * 0.9)].rstrip()
302
+ return [{"role": "user", "content": question[-2000:]}]
303
 
304
 
305
  _LEGACY_RE = re.compile(r"###\s*Instrucci[oó]n:\s*\n(.*?)(?:\n\s*###\s*Entrada:\s*\n(.*?))?\s*\n\s*###\s*Respuesta:\s*$", re.S)
 
529
 
530
 
531
 
 
 
 
 
532
  # ==========================================================================================
533
  # API FastAPI
534
  # ==========================================================================================
 
553
  repetition_penalty: float = Field(CHAT_DEFAULTS["repetition_penalty"], ge=1.0, le=2.0)
554
  no_repeat_ngram: int = Field(0, ge=0, le=8)
555
  seed: Optional[int] = None
556
+ think: Optional[bool] = Field(None, description="Pensar antes de responder (por defecto MILO_THINK; solo si el modelo lo soporta)")
557
+ show_thinking: Optional[bool] = Field(None, description="Incluir el razonamiento en la respuesta (por defecto MILO_SHOW_THINKING)")
558
+ max_think_tokens: Optional[int] = Field(None, ge=8, le=1000)
559
 
560
 
561
  def _history_msgs(messages: List[ChatMessage]) -> Tuple[List[dict], str]:
 
574
 
575
 
576
  def _resolve_request(req: ChatRequest):
577
+ """Devuelve (mensajes, GenConfig, search_mode, think, max_think, show_thinking)."""
578
+ given = req.model_fields_set
579
+ think = THINK_DEFAULT if req.think is None else bool(req.think)
580
+ max_think = req.max_think_tokens or MAX_THINK_TOKENS
581
+ show = SHOW_THINKING if req.show_thinking is None else bool(req.show_thinking)
582
  legacy = parse_legacy_prompt(req.prompt) or (
583
  parse_legacy_prompt(req.messages[-1].content) if (not req.prompt and req.messages and req.messages[-1].role == "user") else None)
 
584
  if legacy is not None and legacy[1]: # con "Entrada" = modo búsqueda web
585
  question, entrada = legacy
586
  p = dict(SEARCH_DEFAULTS)
 
588
  if k in given:
589
  p[k] = getattr(req, k)
590
  p["max_new_tokens"] = min(p["max_new_tokens"], MAX_NEW_TOKENS_HARD_CAP)
591
+ return fit_search_msgs(question, entrada, p["max_new_tokens"], think, max_think), make_gen(p, req.seed), True, think, max_think, show
592
  if legacy is not None:
593
  history, user_text = [], legacy[0]
594
  else:
 
597
  history, user_text = _history_msgs(req.messages)
598
  p = dict(max_new_tokens=req.max_new_tokens, temperature=req.temperature, top_k=req.top_k, top_p=req.top_p,
599
  min_p=req.min_p, repetition_penalty=req.repetition_penalty, no_repeat_ngram=req.no_repeat_ngram)
600
+ return fit_msgs(history, user_text, req.max_new_tokens, think, max_think), make_gen(p, req.seed), False, think, max_think, show
601
 
602
 
603
  @app.on_event("startup")
 
608
  @app.get("/health")
609
  def health():
610
  return {"status": "ok" if _model is not None else "model_not_loaded", "model_repo": MODEL_REPO_ID,
611
+ "quantized": QUANTIZE, "error": _load_error, "stage": _stage, "thinking_model": _tf,
612
+ "speculative": {"tokens": SPEC_TOKENS, **mc.SPEC_STATS},
613
  "web_search": {"tavily_key_set": bool(TAVILY_API_KEY), "fallback": "wikipedia"},
614
  "tokenizer": _tok_info or None}
615
 
 
622
  "d_model": _cfg.d_model, "n_layers": _cfg.n_layers, "n_heads": _cfg.n_heads, "n_kv_heads": _cfg.n_kv_heads,
623
  "context_length": _cfg.context_length, "vocab_size": _cfg.vocab_size, "quantized": QUANTIZE,
624
  "stage": _stage, "use_context": USE_CONTEXT, "num_candidates": NUM_CANDIDATES,
625
+ "thinking_model": _tf, "think_default": THINK_DEFAULT and _tf, "max_think_tokens": MAX_THINK_TOKENS,
626
  "system_prompt": _system, "tokenizer": _tok_info}
627
 
628
 
 
630
  def chat(req: ChatRequest):
631
  if _model is None:
632
  raise HTTPException(503, _load_error or "Modelo no cargado.")
633
+ msgs, gen, search, think, max_think, show = _resolve_request(req)
634
  t0 = time.time()
635
+ r = run_chat(msgs, gen, think, max_think, n=1 if search else NUM_CANDIDATES)
636
+ out = {"response": r.answer, "elapsed_seconds": round(time.time() - t0, 2), "mode": "web_search" if search else "chat"}
637
+ if show and _tf:
638
+ out["thinking"] = r.thinking
639
+ return out
640
 
641
 
642
  @app.post("/chat/stream")
643
  async def chat_stream(req: ChatRequest, request: Request):
644
  if _model is None:
645
  raise HTTPException(503, _load_error or "Modelo no cargado.")
646
+ msgs, gen, search, think, max_think, show = _resolve_request(req)
647
 
648
  async def event_stream():
649
  loop = asyncio.get_running_loop()
650
  cancel = threading.Event()
651
  try:
652
+ if search or NUM_CANDIDATES <= 1 or (_tf and think):
653
+ it = iter_events(msgs, gen, think, max_think, cancel)
654
  sentinel = object()
655
+ announced = False
656
  while True:
657
+ ev = await loop.run_in_executor(None, next, it, sentinel)
658
+ if ev is sentinel:
659
  break
660
+ kind, piece = ev
661
+ if kind == "answer":
662
+ yield f"data: {json.dumps({'delta': piece}, ensure_ascii=False)}\n\n"
663
+ elif show:
664
+ yield f"data: {json.dumps({'thinking_delta': piece}, ensure_ascii=False)}\n\n"
665
+ elif not announced:
666
+ announced = True
667
+ yield f"data: {json.dumps({'status': 'thinking'})}\n\n"
668
  if await request.is_disconnected():
669
  break
670
  else:
671
+ r = await loop.run_in_executor(None, run_chat, msgs, gen, think, max_think, NUM_CANDIDATES)
672
+ for i in range(0, len(r.answer), 4):
673
+ yield f"data: {json.dumps({'delta': r.answer[i:i + 4]}, ensure_ascii=False)}\n\n"
674
  await asyncio.sleep(0.01)
675
  except Exception as e: # noqa
676
  yield f"data: {json.dumps({'error': str(e)}, ensure_ascii=False)}\n\n"
 
711
  items = pick_results(raw, dedupe_domains=(provider != "wikipedia"))
712
  entrada, items = build_entrada(items)
713
  max_new = min(SEARCH_DEFAULTS["max_new_tokens"], MAX_NEW_TOKENS_HARD_CAP)
714
+ max_think = min(MAX_THINK_TOKENS, 160)
715
+ msgs = fit_search_msgs(question, entrada, max_new, THINK_DEFAULT, max_think)
716
  gen = make_gen(dict(SEARCH_DEFAULTS, max_new_tokens=max_new))
717
  t0 = time.time()
718
  try:
719
+ reply = (await loop.run_in_executor(None, run_chat, msgs, gen, THINK_DEFAULT, max_think, 1)).answer
720
  except Exception as e: # noqa
721
  print(f"[Milo X1] web_chat: generación falló: {e}")
722
  return {"fallback": True, "notice": "No pude generar la respuesta con la búsqueda web ahora", "debug": str(e)[:300]}
 
768
  yield _load_error or "Modelo no cargado. Revisa MODEL_REPO_ID del Space."
769
  return
770
  hist = _history_to_msgs(history)[-8:] if USE_CONTEXT else []
771
+ msgs = fit_msgs(hist, message, CHAT_DEFAULTS["max_new_tokens"], THINK_DEFAULT, MAX_THINK_TOKENS)
772
  gen = make_gen(CHAT_DEFAULTS)
773
+ if NUM_CANDIDATES > 1 and not (_tf and THINK_DEFAULT):
774
+ best = run_chat(msgs, gen, False, MAX_THINK_TOKENS, n=NUM_CANDIDATES).answer
775
  for i in range(0, len(best), 4):
776
  yield best[: i + 4]
777
  if not best.strip():
778
  yield "(el modelo no generó texto — prueba otra pregunta o revisa el checkpoint)"
779
  return
780
+ thinking, answer = "", ""
781
+ if _tf and THINK_DEFAULT:
782
+ yield "💭 Pensando…"
783
+ for kind, piece in iter_events(msgs, gen, THINK_DEFAULT, MAX_THINK_TOKENS):
784
+ if kind == "think":
785
+ thinking += piece
786
+ if SHOW_THINKING:
787
+ yield f"💭 {thinking}"
788
+ else:
789
+ answer += piece
790
+ yield (f"💭 {thinking}\n\n{answer}" if (SHOW_THINKING and thinking) else answer)
791
+ if not answer.strip():
792
  yield "(el modelo no generó texto — prueba otra pregunta o revisa el checkpoint)"
793
 
794