px-explorer-v4 / generators.py
BuildBot
push_hf: sparse-branch für HF-Push (nur Code, 0 LFS)
9644d0b
Raw
History Blame Contribute Delete
33.5 kB
"""
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 <image_soft_token> 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 <image_soft_token>
# 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
# <image_soft_token> 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. <end_of_turn>)
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 <end_of_turn> (106) and <end_of_thought> (107) for Gemma IT.
"""
eot_tokens = ["<end_of_turn>", "<end_of_thought>", "<eos>", "</s>"]
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 <image> 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"