"""soup infer — batch inference on a list of prompts.""" import json import time from pathlib import Path from typing import Optional import typer from rich.console import Console from rich.panel import Panel from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn console = Console() def infer( model: str = typer.Option( ..., "--model", "-m", help="Path to model (LoRA adapter or full model)", ), input_file: str = typer.Option( ..., "--input", "-i", help="Path to input JSONL file (each line: {\"prompt\": \"...\"})", ), output_file: str = typer.Option( ..., "--output", "-o", help="Path to output JSONL file for results", ), base: Optional[str] = typer.Option( None, "--base", "-b", help="Base model for LoRA adapter (auto-detected if not set)", ), max_tokens: int = typer.Option( 256, "--max-tokens", min=1, max=16384, help="Maximum tokens to generate per response (1-16384)", ), temperature: float = typer.Option( 0.7, "--temperature", "-t", help="Sampling temperature (0 = greedy)", ), device: Optional[str] = typer.Option( None, "--device", help="Device: cuda, mps, cpu. Auto-detected if not set.", ), ): """Run batch inference on a JSONL file of prompts.""" # Validate input file input_path = Path(input_file) if not input_path.exists(): console.print(f"[red]Input file not found: {input_path}[/]") raise typer.Exit(1) # Validate model path model_path = Path(model) if not model_path.exists(): console.print(f"[red]Model not found: {model_path}[/]") raise typer.Exit(1) # Read prompts prompts = _read_prompts(input_path) if not prompts: console.print("[red]No prompts found in input file.[/]") console.print("[dim]Expected JSONL with {\"prompt\": \"...\"} or plain text lines.[/]") raise typer.Exit(1) # Detect device if not device: from soup_cli.utils.gpu import detect_device device, _ = detect_device() console.print( Panel( f"Model: [bold]{model_path}[/]\n" f"Input: [bold]{input_path}[/] ({len(prompts)} prompts)\n" f"Output: [bold]{output_file}[/]\n" f"Device: [bold]{device}[/]\n" f"Tokens: [bold]{max_tokens}[/]\n" f"Temp: [bold]{temperature}[/]", title="Batch Inference", ) ) # Load model console.print( "[yellow]Warning: loading model with trust_remote_code=True. " "Only use models you trust.[/]" ) console.print("[dim]Loading model...[/]") model_obj, tokenizer = _load_model(str(model_path), base, device) console.print("[green]Model loaded.[/]\n") # Run inference — stream results to disk as they are generated output_path = Path(output_file) total_tokens = 0 num_results = 0 start_time = time.time() with ( open(output_path, "w", encoding="utf-8") as out_f, Progress( SpinnerColumn(), TextColumn("[progress.description]{task.description}"), BarColumn(), TextColumn("[progress.percentage]{task.percentage:>3.0f}%"), TimeElapsedColumn(), console=console, ) as progress, ): task = progress.add_task("Generating...", total=len(prompts)) for prompt_text in prompts: messages = [{"role": "user", "content": prompt_text}] response, token_count = _generate( model_obj, tokenizer, messages, max_tokens=max_tokens, temperature=temperature, ) result = { "prompt": prompt_text, "response": response, "tokens_generated": token_count, } out_f.write(json.dumps(result, ensure_ascii=False) + "\n") out_f.flush() total_tokens += token_count num_results += 1 progress.update(task, advance=1) elapsed = time.time() - start_time tokens_per_sec = total_tokens / elapsed if elapsed > 0 else 0 console.print( Panel( f"Prompts: [bold]{num_results}[/]\n" f"Total tokens: [bold]{total_tokens}[/]\n" f"Duration: [bold]{elapsed:.1f}s[/]\n" f"Throughput: [bold]{tokens_per_sec:.1f} tok/s[/]\n" f"Output: [bold]{output_path}[/]", title="[bold green]Inference Complete![/]", ) ) def _read_prompts(path: Path) -> list[str]: """Read prompts from a JSONL or plain text file.""" prompts = [] with open(path, encoding="utf-8") as f: for raw_line in f: line = raw_line.strip() if not line: continue # Try JSONL try: obj = json.loads(line) if isinstance(obj, dict) and "prompt" in obj: prompts.append(obj["prompt"]) continue except json.JSONDecodeError: pass # Plain text prompts.append(line) return prompts def _load_model(model_path: str, base_model: Optional[str], device: str) -> tuple: """Load a model and tokenizer (reuses diff.py pattern).""" import torch from transformers import AutoModelForCausalLM, AutoTokenizer path = Path(model_path) adapter_config_path = path / "adapter_config.json" is_adapter = adapter_config_path.exists() if is_adapter and not base_model: try: with open(adapter_config_path, encoding="utf-8") as f: config = json.load(f) base_model = config.get("base_model_name_or_path") except (json.JSONDecodeError, OSError): pass if is_adapter and not base_model: console.print( f"[red]Cannot detect base model for {path}. Use --base.[/]" ) raise typer.Exit(1) tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True) if tokenizer.pad_token is None: tokenizer.pad_token = tokenizer.eos_token if is_adapter: from peft import PeftModel base_obj = AutoModelForCausalLM.from_pretrained( base_model, trust_remote_code=True, device_map="auto", dtype=torch.float16, ) model_obj = PeftModel.from_pretrained(base_obj, model_path) else: model_obj = AutoModelForCausalLM.from_pretrained( model_path, trust_remote_code=True, device_map="auto", dtype=torch.float16, ) model_obj.eval() return model_obj, tokenizer def _generate( model, tokenizer, messages, max_tokens=256, temperature=0.7, ) -> tuple[str, int]: """Generate a response from the model. Returns (text, token_count).""" import torch if hasattr(tokenizer, "apply_chat_template") and tokenizer.chat_template: text = tokenizer.apply_chat_template( messages, tokenize=False, add_generation_prompt=True ) else: parts = [] for msg in messages: role = msg["role"] content = msg["content"] if role == "system": parts.append(f"System: {content}") elif role == "user": parts.append(f"User: {content}") elif role == "assistant": parts.append(f"Assistant: {content}") parts.append("Assistant:") text = "\n".join(parts) inputs = tokenizer(text, return_tensors="pt") input_ids = inputs["input_ids"].to(model.device) attention_mask = inputs["attention_mask"].to(model.device) with torch.no_grad(): gen_kwargs = { "input_ids": input_ids, "attention_mask": attention_mask, "max_new_tokens": max_tokens, "do_sample": temperature > 0, "pad_token_id": tokenizer.pad_token_id, } if temperature > 0: gen_kwargs["temperature"] = temperature gen_kwargs["top_p"] = 0.9 outputs = model.generate(**gen_kwargs) new_tokens = outputs[0][input_ids.shape[1]:] token_count = new_tokens.shape[0] response_text = tokenizer.decode(new_tokens, skip_special_tokens=True).strip() return response_text, token_count