""" generators.py — Token Generation (Sync + SSE Streaming) ======================================================== Uses HuggingFace TextIteratorStreamer for streaming. OpenAI-compatible SSE format: data: {json}\n\n, data: [DONE]\n\n """ import torch import json import time from fastapi import HTTPException from transformers import StoppingCriteria, StoppingCriteriaList class StopOnEOT(StoppingCriteria): """Hard-stop when chat delimiters are generated. (SR-61b)""" def __init__(self, stop_ids): self.stop_ids = stop_ids def __call__(self, input_ids: torch.LongTensor, scores: torch.FloatTensor, **kwargs) -> bool: if input_ids.shape[1] == 0: return False # Handle batching if needed, though we serve single-user last_id = input_ids[0, -1].item() return last_id in self.stop_ids import uuid from typing import Generator, Optional, List, Union from schemas import ( ChatMessage, ChatCompletionChunk, StreamChoice, StreamDelta, Role ) def _has_image_content(messages): """True if any message has list-content with image/image_url type items. Used to choose between the multimodal processor route and the text-only tokenizer route. text-only models with image-bearing requests get 400.""" return any( isinstance(m.get("content"), list) and any(isinstance(c, dict) and c.get("type") in ("image", "image_url") for c in m["content"]) for m in messages ) def _extract_images(messages): """Pull PIL.Image objects out of message content lists (base64-decoded from data: URLs) AND replace each image block in the cleaned message with an OpenAI-style `{"type": "image"}` (no URL) so the chat template still counts the image and substitutes placeholders. HTTP(S) URLs are skipped silently (out of scope this iteration) and the image block is dropped from the cleaned content (model sees text only). Returns (cleaned_messages, images_list). Order-preserving.""" from PIL import Image import io, base64 images = [] cleaned = [] for m in messages: content = m.get("content") if isinstance(content, list): new_items = [] text_parts = [] for c in content: if isinstance(c, dict): ctype = c.get("type") if ctype in ("image", "image_url"): url = (c.get("image_url") or {}).get("url", "") if ctype == "image_url" else c.get("url", "") if isinstance(url, str) and url.startswith("data:") and ";base64," in url: b64 = url.split(";base64,", 1)[1] img = Image.open(io.BytesIO(base64.b64decode(b64))).convert("RGB") images.append(img) # Keep a stub image block so the chat template # inserts the right number of # placeholders (one block → one image token run). new_items.append({"type": "image"}) # else: http(s) URL → skip block entirely elif ctype == "text": text_parts.append(c.get("text", "")) new_items.append(c) else: new_items.append(c) elif isinstance(c, str): text_parts.append(c) # Build final content: if all parts were images with no text, # produce a string "" so the role has a content field. Otherwise # use a list of {type:text} blocks plus the kept image stubs. if text_parts: final_content = [{"type": "text", "text": "\n".join(text_parts)}] # Append image stubs at the end (gemma3 chat-template inserts # per "image" dict regardless of position). for it in new_items: if isinstance(it, dict) and it.get("type") == "image": final_content.append(it) else: final_content = new_items if new_items else "" cleaned.append({"role": m.get("role", "user"), "content": final_content}) else: cleaned.append(m) return cleaned, images def _stringify_content(content): """Ensure content is a string for text-only templates.""" if isinstance(content, str): return content if isinstance(content, list): parts = [] for item in content: if isinstance(item, str): parts.append(item) elif isinstance(item, dict): if item.get("type") == "text": parts.append(item.get("text", "")) elif "text" in item and "files" not in item: parts.append(item["text"]) return "\n".join(parts) if isinstance(content, dict): return content.get("text", str(content)) return str(content) def strip_unsupported_model_kwargs(model, gen_kwargs: dict) -> dict: """Entfernt kwargs aus gen_kwargs, die das Model-Forward nicht akzeptiert. Hintergrund (Plan 7.2, 2026-06-30): Llama-basierte Modelle (z.B. MiniCPM5-1B) kennen `token_type_ids` nicht, aber einige Tokenizer (Gemma-kompatibel?) setzen es ungewollt. `model.generate()` validiert model_kwargs VOR dem ersten forward und lehnt unbekannte Keys mit ValueError ab. Vorher: der Strip war dupliziert in generate() und chunked_generate(). Chat-Tab-Pfad (gradio_tabs/chat_tab.py:generate_with_lock) hatte KEINEN Strip → Live-Crash mit minicpm5-1b + ACTIVE_MANIFOLD_RELAY-Preset ("ValueError: The following `model_kwargs` are not used by the model: ['token_type_ids']"). Diese Funktion ist die single source of truth: alle Aufrufer von model.generate(**kwargs) müssen sie vorher rufen. Implementierung: targeted Liste bekannter "Llama-weiß-nicht"-kwargs (`token_type_ids`). Generische Heuristik (alle kwargs prüfen, ob in forward.co_varnames) ist zu aggressiv — `streamer`, `max_new_tokens`, `do_sample`, `temperature` etc. sind generate()-spezifisch, NICHT forward()-spezifisch, würden aber fälschlich gestrippt. Erkenntnis: model.generate() selbst akzeptiert diese generate-spezifischen kwargs (sie sind Teil von GenerationConfig), nur model.forward nicht. transformers' _validate_model_kwargs prüft beide Schichten. """ # Targeted: nur kwargs, von denen wir empirisch wissen, dass sie # model.generate() bei Llama-Pfaden crashen. KNOWN_LLAMA_UNSUPPORTED = ("token_type_ids",) forward = getattr(model, "forward", None) if forward is not None and hasattr(forward, "__code__"): supported = set(forward.__code__.co_varnames) return { k: v for k, v in gen_kwargs.items() if k not in KNOWN_LLAMA_UNSUPPORTED or k in supported } # Fallback: forward nicht inspizierbar — generischer Strip auf # known-unsupported kwargs. return {k: v for k, v in gen_kwargs.items() if k not in KNOWN_LLAMA_UNSUPPORTED} def _px_gen_kwargs(model, base: dict) -> dict: """Inject PX-specific kwargs (e.g. repetition_penalty, no_repeat_ngram_size) onto a generation kwargs dict. The patched model exposes `_px_repetition_penalty` as a model-level attribute when SR-59 / token-loop mitigations set a value > 1.0. We pass it through to `model.generate(...)` so the mitigation actually takes effect — otherwise the dict lives on `_px_config` and is never read. The attrs live on the text_model (e.g. model.model.language_model or model.model), not the top-level wrapper — we walk named_modules to find whichever sub-model has `_px_repetition_penalty` set. """ base = dict(base) rp = _find_px_attr(model, "_px_repetition_penalty", default=1.0) or 1.0 # Guard: only inject if the value is a real number > 1.0 if isinstance(rp, (int, float)) and rp > 1.0: base["repetition_penalty"] = rp # For Gemma 4 in particular, long generations (≥200 tokens) drift # into a 4-token attractor loop even with rp=1.15. The # no_repeat_ngram_size=3 n-gram constraint catches the loop without # the brittleness of raising rp further (which destroys natural # repetition in German compounds). It's a no-op for short outputs. ngram = _find_px_attr(model, "_px_no_repeat_ngram_size", default=0) or 0 if isinstance(ngram, (int, float)) and ngram: base["no_repeat_ngram_size"] = int(ngram) # SR-61b: Hard-stop criteria for chat delimiters (e.g. ) eos_ids = base.get("eos_token_id", []) if isinstance(eos_ids, int): eos_ids = [eos_ids] if eos_ids: clean_ids = list(set([int(eid) for eid in eos_ids if eid is not None])) if clean_ids: base["stopping_criteria"] = StoppingCriteriaList([StopOnEOT(clean_ids)]) # Plan 3 Phase B/C: Auto-use_cache=False für lange Inputs auf 4b/E2B. # Begründung: mit PX-Patch (full-attention über alle Tokens) ist der # KV-Cache buildup bei T>4500 nicht in 12GB haltbar. Plan 3 Phase D: # wir setzen einen Marker `_px_use_chunked_prefill` statt use_cache=False. # Der Aufrufer (chat_completion / stream_chat_completion) erkennt den # Marker und ruft chunked_generate aus scratches/4b-image/chunked_prefill # statt model.generate. chunked_generate nutzt full-only cache + # chunked attention = 6.4 GB peak bei T=8002 (vs OOM bei full generate # und vs 196s bei use_cache=False). # # NICHT angewendet wenn: # - User hat use_cache explizit gesetzt (base["use_cache"] ist nicht None) # - Model ist 1b/270m (passt locker in 12GB) # - Input ist klein (< T_THRESHOLD) if base.get("use_cache") is None: input_len = base.get("_input_len", 0) is_small_model = _is_small_model(model) if not is_small_model and input_len > _LONG_INPUT_THRESHOLD: base["_px_use_chunked_prefill"] = True import sys as _sys print(f"[generate] auto chunked_prefill (input_len={input_len} > " f"{_LONG_INPUT_THRESHOLD}; use_cache=False würde langsam, " f"chunked_prefill ist 6x schneller)", file=_sys.stderr) base.pop("_input_len", None) # cleanup internal marker return base # Threshold: bei 4b + int8 funktioniert generate mit use_cache=True bis ~4500. # Plan 3: Schwellwert für Auto-Switch zu chunked_prefill (Plan 3 Phase D). # Empirisch gemessen mit profile_threshold_sweep.py (T=4000..9000, 4b+int8+PX): # use_cache=True funktioniert bis T=9000 ohne OOM (peak 7.67 GB, # 3.89 GB headroom auf 11.56 GB GPU). InfLLM/chunked-attention macht # Score-Matrix-Materialisierung bounded, deshalb skaliert das linear # mit T statt quadratisch. # Vorher: 4500 → cross_model_holographic_01 (T=5371 text-only via # streaming_bridge) lief durch chunked_generate (~22.8s) statt direkt # use_cache=True (~19.6s, 14% schneller). # Mit 8800 als Schwellwert landen ALLE realistischen Session-Größen # (≤ 9k tokens) im use_cache=True-Pfad; chunked_generate bleibt als # Fallback für extreme Längen (>8800) erhalten. # Quelle: scratches/4b-image/profile_threshold_sweep_results.json _LONG_INPUT_THRESHOLD = 8800 def _is_small_model(model) -> bool: """True wenn Model klein genug ist dass long-context use_cache=True OK ist. Heuristik: Anzahl Layer × hidden_size ist Proxy für Parameter-Count. 270m (12 L × 640 H) und 1b (26 L × 1152 H) sind "klein" (low VRAM pressure). 4b (34 L × 2560 H) und größer brauchen use_cache=False bei long inputs. """ try: n_layers = 0 hidden = 0 for _, mod in model.named_modules(): if hasattr(mod, "layers") and hasattr(mod, "rotary_emb"): n_layers = len(mod.layers) # hidden size via erstes embed if hasattr(mod, "embed_tokens"): hidden = mod.embed_tokens.embedding_dim break return (n_layers * hidden) < 30_000 # 270m=7680, 1b=29952, 4b=87040 except Exception: return False def _find_px_attr(model, attr: str, default=None): """Walk the module tree to find the first submodule carrying `attr`.""" val = getattr(model, attr, None) if val is not None: return val for _, mod in model.named_modules(): val = getattr(mod, attr, None) if val is not None: return val return default def _inject_eot_eos(base: dict, tokenizer) -> dict: """Robust injection of chat-specific end tokens. Handles (106) and (107) for Gemma IT. """ eot_tokens = ["", "", "", ""] eot_ids = [] for t in eot_tokens: tid = tokenizer.convert_tokens_to_ids(t) if tid is not None and tid != tokenizer.unk_token_id: eot_ids.append(tid) # Also add standard EOS if not present if tokenizer.eos_token_id is not None: eot_ids.append(tokenizer.eos_token_id) eot_ids = list(set(eot_ids)) # Unique eos_field = base.get("eos_token_id") if isinstance(eos_field, int): eos_ids = [eos_field] elif isinstance(eos_field, list): eos_ids = list(eos_field) else: eos_ids = [] for eid in eot_ids: if eid not in eos_ids: eos_ids.append(eid) base["eos_token_id"] = eos_ids # Set pad_token_id to first EOS if not set if base.get("pad_token_id") is None and eos_ids: base["pad_token_id"] = eos_ids[0] return base def _make_chunk( completion_id: str, created: int, model_id: str, index: int, delta: dict, finish_reason: Optional[str] = None ) -> ChatCompletionChunk: """Create a streaming chunk in OpenAI format.""" return ChatCompletionChunk( id=completion_id, created=created, model=model_id, choices=[StreamChoice( index=index, delta=StreamDelta(**delta), finish_reason=finish_reason, )], ) async def generate_chat_completion( model_entry: dict, messages: list, temperature: float, top_p: float, max_tokens: int, stop: Optional[Union[str, List[str]]] = None, ) -> dict: """Non-streaming chat completion. Returns text + token counts.""" model = model_entry["model"] tokenizer = model_entry["tokenizer"] processor = model_entry.get("processor") has_images = _has_image_content(messages) _force_chunked = False # Plan 4: chunked-vision-encoder Pfad if has_images and processor is None: raise HTTPException( status_code=400, detail="Model does not support images (text-only model). Use a multimodal model (gemma3-4b-it) or remove the image." ) if has_images: # Gemma3 multimodal pattern (Transformers >=4.50): # 1) apply_chat_template with tokenize=False → text with placeholders # 2) processor(text=..., images=...) → BatchFeature with input_ids + pixel_values # apply_chat_template(..., tokenize=True, images=...) errors on Gemma3Processor # because it forwards `images=...` internally and the kwarg collides. # # Plan 4: chunked-vision-encoding. Bei 3+ Bildern allokiert der # standard processor >6 GB extra für Vision-Tower activations # → peak 11+ GB → OOM auf 12 GB GPU. Chunked encoder macht 1 Bild # pro forward und löscht intermediates → peak ~7 GB. # Auch bei kurzem Input: chunked encoder ist sicherer, ABER langsamer # (~3× wegen 3× vision_tower forward). Switch-Logik: # - N images > CHUNKED_VISION_THRESHOLD: chunked encoder # - sonst: standard processor (schneller) cleaned, images = _extract_images(messages) n_images = len(images) CHUNKED_VISION_THRESHOLD = 2 # >2 Bilder → chunked if n_images > CHUNKED_VISION_THRESHOLD: try: from chunked_vision_encoder import chunked_process_multimodal chunked_inputs = chunked_process_multimodal( model, processor, tokenizer, cleaned, images, ) # Replace `inputs` with the chunked-encoder result, in a form # that the downstream code can dispatch to chunked_generate # via the use_chunked branch below. inputs = { "input_ids": chunked_inputs["input_ids"], "inputs_embeds": chunked_inputs["inputs_embeds"], "attention_mask": chunked_inputs["attention_mask"], } # Force chunked path (chunked_generate with inputs_embeds) _force_chunked = True import sys as _sys print(f"[generate] multimodal {n_images} images → " f"chunked-vision-encoder (Plan 4)", file=_sys.stderr) except Exception as e: import sys as _sys print(f"[generate] chunked-vision-encoder failed " f"({type(e).__name__}: {str(e)[:200]}) → fallback " f"to standard processor", file=_sys.stderr) prompt_text = processor.apply_chat_template( cleaned, tokenize=False, add_generation_prompt=True ) inputs = processor(text=prompt_text, images=images, return_tensors="pt").to(model.device) _force_chunked = False else: prompt_text = processor.apply_chat_template( cleaned, tokenize=False, add_generation_prompt=True ) inputs = processor(text=prompt_text, images=images, return_tensors="pt").to(model.device) _force_chunked = False else: # Text-only path (unchanged from prior behavior). processed_messages = [{"role": m.get("role", "user"), "content": _stringify_content(m.get("content", ""))} for m in messages] input_text = tokenizer.apply_chat_template( processed_messages, tokenize=False, add_generation_prompt=True ) inputs = tokenizer(input_text, return_tensors="pt").to(model.device) # Plan 7.2: Llama-Modelle (z.B. MiniCPM5-1B) kennen token_type_ids # nicht. Single source of truth: strip_unsupported_model_kwargs. inputs = strip_unsupported_model_kwargs(model, inputs) input_len = inputs["input_ids"].shape[1] # Generate gen_kwargs = dict( max_new_tokens=max_tokens, temperature=temperature if temperature > 0 else 1e-10, top_p=top_p, do_sample=temperature > 0, ) if stop: stop_list = stop if isinstance(stop, list) else [stop] gen_kwargs["stop_strings"] = stop_list gen_kwargs["tokenizer"] = tokenizer # Plan 3: _input_len signals _px_gen_kwargs whether to auto-disable # use_cache for long inputs (4b/E2B with T > 4500) gen_kwargs["_input_len"] = input_len gen_kwargs = _px_gen_kwargs(model, gen_kwargs) gen_kwargs = _inject_eot_eos(gen_kwargs, tokenizer) with torch.no_grad(): use_chunked = gen_kwargs.pop("_px_use_chunked_prefill", False) # Plan 4: chunked-vision-encoder liefert pre-merged inputs_embeds. # In diesem Fall MUSS chunked_generate laufen (sonst kriegt das # Model die Vision-Embeds nicht). if _force_chunked: use_chunked = True # Multimodal + chunked: Vision-Tokens werden vom processor an einer # festen Position eingefügt, die nicht zwingend im ersten Chunk # liegt. Aktuell nicht unterstützt — fallback auf model.generate # mit use_cache=False (T>4500 Pfad). is_multimodal = inputs.get("pixel_values") is not None if use_chunked and is_multimodal: gen_kwargs["use_cache"] = False import sys as _sys print(f"[generate] multimodal + long context (T={input_len}) → " f"use_cache=False Fallback (chunked_generate hat noch " f"keine Vision-Token-Position-Heuristik)", file=_sys.stderr) outputs = model.generate(**inputs, **gen_kwargs) elif use_chunked: # Plan 3 Phase D: chunked_generate für lange Inputs (4b + T>4500). # Spart VRAM (chunked attention + full-only cache) und ist # signifikant schneller als use_cache=False. Greift nur, wenn # _px_gen_kwargs den Marker gesetzt hat (langer Input, kein # user-override, nicht-small-model). try: from chunked_prefill import chunked_generate except ImportError: # Fallback: scratches/4b-image nicht im path. Versuche es # explizit zu laden. import os as _os, sys as _sys _SCRATCHES = _os.path.join( _os.path.dirname(_os.path.abspath(__file__)), "scratches", "4b-image", ) if _SCRATCHES not in _sys.path: _sys.path.insert(0, _SCRATCHES) from chunked_prefill import chunked_generate eos_id = tokenizer.eos_token_id outputs = chunked_generate( model, inputs["input_ids"], max_new_tokens=gen_kwargs.get("max_new_tokens", 64), do_sample=gen_kwargs.get("do_sample", False), eos_token_id=eos_id, pixel_values=inputs.get("pixel_values"), **({"token_type_ids": inputs["token_type_ids"]} if inputs.get("token_type_ids") is not None else {}), # Plan 4: chunked-vision-encoder liefert pre-merged # text+image embeddings → inputs_embeds statt pixel_values. # Wenn chunked-vision-encoder aktiv war, ist # inputs["inputs_embeds"] gesetzt und inputs["pixel_values"] # NICHT gesetzt. inputs_embeds=inputs.get("inputs_embeds"), ) else: outputs = model.generate(**inputs, **gen_kwargs) # Decode only new tokens new_tokens = outputs[0][input_len:] text = tokenizer.decode(new_tokens, skip_special_tokens=True) # Trim at stop strings (safety net) if stop: stop_list = stop if isinstance(stop, list) else [stop] for s in stop_list: idx = text.find(s) if idx >= 0: text = text[:idx] completion_tokens = len(new_tokens) return { "text": text, "prompt_tokens": input_len, "completion_tokens": completion_tokens, } async def generate_chat_completion_stream( model_entry: dict, messages: list, temperature: float, top_p: float, max_tokens: int, stop: Optional[Union[str, List[str]]] = None, model_id: str = "", ) -> Generator[str, None, None]: """Streaming SSE generator for chat completions. Yields lines in OpenAI SSE format: data: {json}\n\n ... data: [DONE]\n\n """ from transformers import TextIteratorStreamer from threading import Thread model = model_entry["model"] tokenizer = model_entry["tokenizer"] processor = model_entry.get("processor") has_images = _has_image_content(messages) if has_images and processor is None: # Cannot raise HTTPException from inside a sync generator after the SSE # response has started. Yield a single error chunk then [DONE] so the # bridge surfaces a readable message instead of dying silently. err = {"error": {"message": "Model does not support images (text-only model). Use a multimodal model (gemma3-4b-it) or remove the image.", "type": "invalid_request_error"}} yield f"data: {json.dumps(err)}\n\n" yield "data: [DONE]\n\n" return if has_images: cleaned, images = _extract_images(messages) prompt_text = processor.apply_chat_template( cleaned, tokenize=False, add_generation_prompt=True ) inputs = processor(text=prompt_text, images=images, return_tensors="pt").to(model.device) else: processed_messages = [{"role": m.get("role", "user"), "content": _stringify_content(m.get("content", ""))} for m in messages] input_text = tokenizer.apply_chat_template( processed_messages, tokenize=False, add_generation_prompt=True ) inputs = tokenizer(input_text, return_tensors="pt").to(model.device) # Plan 7.2: Llama-Modelle (z.B. MiniCPM5-1B) kennen token_type_ids # nicht. Single source of truth: strip_unsupported_model_kwargs. inputs = strip_unsupported_model_kwargs(model, inputs) input_len = inputs["input_ids"].shape[1] # Setup streamer streamer = TextIteratorStreamer( tokenizer, skip_prompt=True, skip_special_tokens=True ) # Run generation in background thread gen_kwargs = dict( **inputs, max_new_tokens=max_tokens, temperature=temperature if temperature > 0 else 1e-10, top_p=top_p, do_sample=temperature > 0, streamer=streamer, ) if stop: stop_list = stop if isinstance(stop, list) else [stop] gen_kwargs["stop_strings"] = stop_list gen_kwargs["tokenizer"] = tokenizer # Plan 3: _input_len signals _px_gen_kwargs whether to auto-disable # use_cache for long inputs (4b/E2B with T > 4500) gen_kwargs["_input_len"] = input_len gen_kwargs = _px_gen_kwargs(model, gen_kwargs) gen_kwargs = _inject_eot_eos(gen_kwargs, tokenizer) # Plan 3 Phase D: bei langem Input + 4b/E2B nutzen wir chunked_generate # mit Streamer statt model.generate (10x schneller als use_cache=False). # Multimodal + chunked: aktuell nicht unterstützt (Vision-Token-Position # ist nicht in erstem Chunk garantiert). Fallback use_cache=False. use_chunked_stream = gen_kwargs.pop("_px_use_chunked_prefill", False) is_multimodal = inputs.get("pixel_values") is not None if use_chunked_stream and is_multimodal: gen_kwargs["use_cache"] = False import sys as _sys print(f"[generate_stream] multimodal + long context (T={input_len})" f" → use_cache=False Fallback (kein chunked+vision)", file=_sys.stderr) thread = Thread(target=model.generate, kwargs=gen_kwargs) thread.start() elif use_chunked_stream: # Inline-Import: scratches/4b-image ist nicht im Standard-Pfad. try: from chunked_prefill import chunked_generate as _chunked_generate except ImportError: import os as _os, sys as _sys _SCRATCHES = _os.path.join( _os.path.dirname(_os.path.abspath(__file__)), "scratches", "4b-image", ) if _SCRATCHES not in _sys.path: _sys.path.insert(0, _SCRATCHES) from chunked_prefill import chunked_generate as _chunked_generate eos_field = gen_kwargs.pop("eos_token_id", None) # _inject_eot_eos setzt eos_token_id auf eine Liste — wir brauchen # einen einzelnen int für chunked_generate. if isinstance(eos_field, list): eos_id = eos_field[0] if eos_field else None elif isinstance(eos_field, int): eos_id = eos_field else: eos_id = tokenizer.eos_token_id do_sample = gen_kwargs.get("do_sample", False) def _chunked_worker(): try: _chunked_generate( model, inputs["input_ids"], max_new_tokens=max_tokens, do_sample=do_sample, eos_token_id=eos_id, streamer=streamer, pixel_values=inputs.get("pixel_values"), **({"token_type_ids": inputs["token_type_ids"]} if inputs.get("token_type_ids") is not None else {}), ) except Exception as _e: import traceback as _tb _tb.print_exc() try: streamer.end() except Exception: pass thread = Thread(target=_chunked_worker) thread.start() else: thread = Thread(target=model.generate, kwargs=gen_kwargs) thread.start() # SSE format completion_id = f"chatcmpl-{uuid.uuid4().hex[:12]}" created = int(time.time()) # First chunk: role chunk = _make_chunk(completion_id, created, model_id, 0, delta={"role": "assistant"}, finish_reason=None) yield f"data: {chunk.model_dump_json()}\n\n" # Stream tokens for text in streamer: if text: chunk = _make_chunk(completion_id, created, model_id, 0, delta={"content": text}, finish_reason=None) yield f"data: {chunk.model_dump_json()}\n\n" thread.join() # Final chunk: finish_reason chunk = _make_chunk(completion_id, created, model_id, 0, delta={}, finish_reason="stop") yield f"data: {chunk.model_dump_json()}\n\n" yield "data: [DONE]\n\n" async def generate_completion( model_entry: dict, prompt: str, temperature: float, top_p: float, max_tokens: int, stop: Optional[Union[str, List[str]]] = None, ) -> dict: """Non-streaming text completion. Returns text + token counts.""" model = model_entry["model"] tokenizer = model_entry["tokenizer"] inputs = tokenizer(prompt, return_tensors="pt").to(model.device) input_len = inputs["input_ids"].shape[1] gen_kwargs = dict( max_new_tokens=max_tokens, temperature=temperature if temperature > 0 else 1e-10, top_p=top_p, do_sample=temperature > 0, ) if stop: stop_list = stop if isinstance(stop, list) else [stop] gen_kwargs["stop_strings"] = stop_list gen_kwargs["tokenizer"] = tokenizer # Plan 3: _input_len signals _px_gen_kwargs whether to auto-disable # use_cache for long inputs (4b/E2B with T > 4500) gen_kwargs["_input_len"] = input_len gen_kwargs = _px_gen_kwargs(model, gen_kwargs) gen_kwargs = _inject_eot_eos(gen_kwargs, tokenizer) with torch.no_grad(): outputs = model.generate(**inputs, **gen_kwargs) new_tokens = outputs[0][input_len:] text = tokenizer.decode(new_tokens, skip_special_tokens=True) if stop: stop_list = stop if isinstance(stop, list) else [stop] for s in stop_list: idx = text.find(s) if idx >= 0: text = text[:idx] return { "text": text, "prompt_tokens": input_len, "completion_tokens": len(new_tokens), } async def generate_completion_stream( model_entry: dict, prompt: str, temperature: float, top_p: float, max_tokens: int, stop: Optional[Union[str, List[str]]] = None, model_id: str = "", ) -> Generator[str, None, None]: """Streaming SSE generator for text completions.""" from transformers import TextIteratorStreamer from threading import Thread model = model_entry["model"] tokenizer = model_entry["tokenizer"] inputs = tokenizer(prompt, return_tensors="pt").to(model.device) streamer = TextIteratorStreamer( tokenizer, skip_prompt=True, skip_special_tokens=True ) gen_kwargs = dict( **inputs, max_new_tokens=max_tokens, temperature=temperature if temperature > 0 else 1e-10, top_p=top_p, do_sample=temperature > 0, streamer=streamer, ) if stop: stop_list = stop if isinstance(stop, list) else [stop] gen_kwargs["stop_strings"] = stop_list gen_kwargs["tokenizer"] = tokenizer # Plan 3: _input_len signals _px_gen_kwargs whether to auto-disable # use_cache for long inputs (4b/E2B with T > 4500) gen_kwargs["_input_len"] = input_len gen_kwargs = _px_gen_kwargs(model, gen_kwargs) gen_kwargs = _inject_eot_eos(gen_kwargs, tokenizer) thread = Thread(target=model.generate, kwargs=gen_kwargs) thread.start() completion_id = f"cmpl-{uuid.uuid4().hex[:12]}" created = int(time.time()) for text in streamer: if text: chunk = { "id": completion_id, "object": "text_completion", "created": created, "model": model_id, "choices": [{ "text": text, "index": 0, "finish_reason": None, }], } yield f"data: {json.dumps(chunk)}\n\n" thread.join() # Final chunk chunk = { "id": completion_id, "object": "text_completion", "created": created, "model": model_id, "choices": [{ "text": "", "index": 0, "finish_reason": "stop", }], } yield f"data: {json.dumps(chunk)}\n\n" yield "data: [DONE]\n\n"