"""soup serve — local inference server with OpenAI-compatible API.""" import json import logging import re import time import uuid from pathlib import Path from typing import TYPE_CHECKING, Any, Dict, List, Optional import typer if TYPE_CHECKING: # pragma: no cover from collections.abc import Generator from rich.console import Console from rich.panel import Panel logger = logging.getLogger(__name__) console = Console() def _validate_adapter_name(name: str) -> bool: """Validate adapter name: alphanumeric + hyphens only.""" if not name: return False return bool(re.match(r'^[a-zA-Z0-9][a-zA-Z0-9\-]*$', name)) def _validate_adapter_path(path: str, cwd: Optional[str] = None) -> bool: """Validate adapter path: must exist and stay under cwd.""" if cwd is None: cwd = str(Path.cwd()) try: resolved = Path(path).resolve() cwd_resolved = Path(cwd).resolve() resolved.relative_to(cwd_resolved) return resolved.exists() except (ValueError, OSError): return False def _parse_adapters(adapters: Optional[List[str]]) -> Dict[str, str]: """Parse adapter name=path pairs from CLI flag. Returns dict mapping adapter name → path string. Raises ValueError on invalid format. """ if not adapters: return {} result = {} for item in adapters: if "=" not in item: raise ValueError( f"Invalid adapter format: '{item}'. Expected key=path format." ) name, path = item.split("=", 1) result[name.strip()] = path.strip() return result def serve( model: str = typer.Option( ..., "--model", "-m", help="Path to LoRA adapter directory or full model", ), base_model: Optional[str] = typer.Option( None, "--base", "-b", help="Base model ID. Auto-detected from adapter_config.json if not set.", ), port: int = typer.Option( 8000, "--port", "-p", help="Port to serve on", ), host: str = typer.Option( "0.0.0.0", "--host", help="Host to bind to", ), device: Optional[str] = typer.Option( None, "--device", help="Device: cuda, mps, cpu. Auto-detected if not set.", ), max_tokens_default: int = typer.Option( 512, "--max-tokens", help="Default max tokens for generation", ), backend: str = typer.Option( "transformers", "--backend", help="Inference backend: transformers (default), vllm, sglang, or mii", ), tensor_parallel: int = typer.Option( 1, "--tensor-parallel", "--tp", help="Number of GPUs for tensor parallelism (vLLM only)", ), gpu_memory_utilization: float = typer.Option( 0.9, "--gpu-memory", help="Fraction of GPU memory to use (vLLM only, 0.0-1.0)", ), speculative_model: Optional[str] = typer.Option( None, "--speculative-decoding", help="Draft model for speculative decoding (smaller/faster model ID or path)", ), num_speculative_tokens: int = typer.Option( 5, "--num-speculative-tokens", help="Number of tokens the draft model generates per step (speculative decoding)", ), adapters: Optional[List[str]] = typer.Option( None, "--adapters", help="LoRA adapters as name=path pairs (repeatable). E.g. chat=./chat-adapter", ), prefix_cache: bool = typer.Option( False, "--prefix-cache", help="Enable vLLM prefix caching for shared system prompts (RAG/agent workloads).", ), auto_spec: bool = typer.Option( False, "--auto-spec", help="Auto-pair draft model for speculative decoding based on target model.", ), structured_output: str = typer.Option( "off", "--structured-output", help="Constrain generation: off (default) | json | regex.", ), json_schema: Optional[str] = typer.Option( None, "--json-schema", help="Path to JSON schema file (used with --structured-output json).", ), regex_pattern: Optional[str] = typer.Option( None, "--regex-pattern", help="Regex pattern (used with --structured-output regex).", ), dashboard: bool = typer.Option( False, "--dashboard", help="Enable live continuous-batching dashboard + /metrics endpoint.", ), trace: bool = typer.Option( False, "--trace", help="Enable OpenTelemetry request tracing (requires opentelemetry-sdk).", ), trace_endpoint: Optional[str] = typer.Option( None, "--trace-endpoint", help="OTLP endpoint URL (default: http://localhost:4317).", ), auto_quant: bool = typer.Option( False, "--auto-quant", help="Try GGUF/AWQ/GPTQ/FP8 on a tiny eval, pick fastest-at-acceptable-quality.", ), trust_remote_code: bool = typer.Option( False, "--trust-remote-code", help=( "Allow loading models that ship custom Python via auto_map. " "Default deny (v0.36.0). Only enable if you trust the source." ), ), trace_log: Optional[str] = typer.Option( None, "--trace-log", help=( "Append per-request {prompt, response, latency_ms, tokens, ts} " "to JSONL at this path. Path must stay under cwd. Rotates at " "100 MB (one backup retained). Added in v0.40.3 (#33)." ), ), trace_log_cap_mb: int = typer.Option( 100, "--trace-log-cap-mb", help="Rotation cap in MB for --trace-log (1 - 10000). Default 100.", ), reasoning_parser: Optional[str] = typer.Option( None, "--reasoning-parser", help=( "Strip reasoning-trace blocks from responses. One of: " "deepseek-r1 | qwen3 | phi4 | openthinker. v0.53.9 #98." ), ), hub: str = typer.Option( "hf", "--hub", help=( "Source hub for the base model: hf (default) / modelscope / " "modelers. Non-HF hubs require the matching SDK (v0.53.10 #152)." ), ), ): """Start a local inference server with OpenAI-compatible API.""" # v0.53.10 #152 — pre-fetch base from a non-HF hub before serve starts. if hub and hub != "hf": from soup_cli.utils.hubs import apply_hub_to_cli_model try: model, base_model = apply_hub_to_cli_model( model, base_model, hub, console=console ) except (TypeError, ValueError) as exc: console.print(f"[red]{exc}[/]") raise typer.Exit(code=2) from exc except ImportError as exc: console.print(f"[red]{exc}[/]") raise typer.Exit(code=1) from exc # Lazy imports for fast CLI startup try: import uvicorn # noqa: F401 from fastapi import FastAPI # noqa: F401 from fastapi.responses import StreamingResponse # noqa: F401 except ImportError: console.print( "[red]FastAPI/uvicorn not installed.[/]\n" "Install with: [bold]pip install 'soup-cli[serve]'[/]" ) raise typer.Exit(1) # Validate backend backend = backend.lower() if backend not in ("transformers", "vllm", "sglang", "mii"): console.print( f"[red]Unknown backend: {backend}[/]\n" "Supported backends: [bold]transformers[/], [bold]vllm[/], " "[bold]sglang[/], [bold]mii[/]" ) raise typer.Exit(1) # DeepSpeed-MII v0.27.0: dependency check only — live pipeline wiring # ships in v0.27.1 once we stabilize the OpenAI-compat shim. We exit # with code 1 (not 0) so scripts / CI fail loudly rather than silently # treating `--backend mii` as "server started". if backend == "mii": from soup_cli.utils.mii import ( build_mii_app, create_mii_pipeline, is_mii_available, ) if not is_mii_available(): console.print( "[red]deepspeed-mii is not installed.[/]\n" "Install with: [bold]pip install deepspeed-mii[/]" ) raise typer.Exit(1) # v0.33.0 #38 — live MII pipeline + OpenAI-compatible HTTP. try: mii_pipeline = create_mii_pipeline( model_path=model, tensor_parallel=1, max_length=4096, ) except (ImportError, RuntimeError, OSError) as exc: console.print(f"[red]Failed to create MII pipeline:[/] {exc}") raise typer.Exit(1) from exc mii_model_name = Path(model).name mii_app = build_mii_app(mii_pipeline, model_name=mii_model_name) import uvicorn console.print( f"[green]Starting DeepSpeed-MII server[/] " f"({mii_model_name}) on http://{host}:{port}" ) uvicorn.run(mii_app, host=host, port=port, log_level="info") return # Auto-detect vLLM/SGLang: if installed but not selected, show hint if backend == "transformers": from soup_cli.utils.vllm import is_vllm_available if is_vllm_available(): console.print( "[dim]Hint: vLLM is installed. Use [bold]--backend vllm[/] " "for 2-4x better throughput.[/]" ) else: from soup_cli.utils.sglang import check_sglang_available if check_sglang_available(): console.print( "[dim]Hint: SGLang is installed. Use [bold]--backend sglang[/] " "for high-throughput serving.[/]" ) # Validate vLLM availability if backend == "vllm": from soup_cli.utils.vllm import is_vllm_available if not is_vllm_available(): console.print( "[red]vLLM not installed.[/]\n" "Install with: [bold]pip install 'soup-cli[serve-fast]'[/]" ) raise typer.Exit(1) # Validate SGLang availability if backend == "sglang": from soup_cli.utils.sglang import check_sglang_available if not check_sglang_available(): console.print( "[red]SGLang not installed.[/]\n" "Install with: [bold]pip install 'soup-cli[sglang]'[/]" ) raise typer.Exit(1) # Parse and validate multi-adapter map try: adapter_map = _parse_adapters(adapters) except ValueError as exc: console.print(f"[red]{exc}[/]") raise typer.Exit(1) if adapter_map and backend != "transformers": console.print( f"[red]--adapters is only supported with --backend transformers.[/]\n" f"Multi-adapter serving for {backend} is not yet implemented." ) raise typer.Exit(1) cwd = str(Path.cwd()) for adapter_name, adapter_path in adapter_map.items(): if not _validate_adapter_name(adapter_name): console.print( f"[red]Invalid adapter name: '{adapter_name}'[/]\n" "Names must be alphanumeric + hyphens (e.g., 'chat', 'code-v2')." ) raise typer.Exit(1) if not _validate_adapter_path(adapter_path, cwd=cwd): console.print( f"[red]Invalid adapter path: '{adapter_path}'[/]\n" "Path must exist and be under the current working directory." ) raise typer.Exit(1) model_path = Path(model) if not model_path.exists(): console.print(f"[red]Model path not found: {model_path}[/]") raise typer.Exit(1) # Detect adapter adapter_config_path = model_path / "adapter_config.json" is_adapter = adapter_config_path.exists() # Resolve base model if is_adapter and not base_model: base_model = _detect_base_model(adapter_config_path) if not base_model: console.print( "[red]Cannot detect base model from adapter_config.json.[/]\n" "Please specify with [bold]--base[/] flag." ) raise typer.Exit(1) # Detect device (only for transformers backend) if not device and backend == "transformers": from soup_cli.utils.gpu import detect_device device, _ = detect_device() elif not device: device = "cuda" backend_labels = {"vllm": "vLLM", "sglang": "SGLang", "transformers": "transformers"} backend_label = backend_labels.get(backend, backend) console.print( Panel( f"Model: [bold]{model_path}[/]\n" + (f"Base: [bold]{base_model}[/]\n" if is_adapter else "") + f"Device: [bold]{device}[/]\n" f"Type: [bold]{'LoRA adapter' if is_adapter else 'Full model'}[/]\n" f"Backend: [bold]{backend_label}[/]" + (f"\nTP: [bold]{tensor_parallel}[/]" if backend == "vllm" else ""), title="Loading model", ) ) # Auto-pair draft model for speculative decoding if auto_spec and not speculative_model: from soup_cli.utils.spec_pairing import pick_draft_model target_for_pairing = base_model or str(model_path) paired = pick_draft_model(target_for_pairing) if paired: speculative_model = paired console.print( f"[green]Auto-paired draft model:[/] {paired} " f"(target: {target_for_pairing})" ) else: console.print( f"[yellow]--auto-spec: no known draft model for " f"{target_for_pairing}. Skipping speculative decoding.[/]" ) # Validate structured-output flags up front from soup_cli.utils.structured_output import validate_mode try: structured_mode = validate_mode(structured_output) except ValueError as exc: console.print(f"[red]{exc}[/]") raise typer.Exit(1) if structured_mode == "regex" and not regex_pattern: console.print("[red]--structured-output regex requires --regex-pattern.[/]") raise typer.Exit(1) if structured_mode == "json" and not json_schema: console.print( "[red]--structured-output json requires --json-schema .[/]" ) raise typer.Exit(1) # v0.33.0 #54 / v0.35.0 #61 — Auto-quant live picker. Runs a tiny eval # over a fixed prompt set across candidate quantisations, picks the best # by (score, -latency), then forwards the picked candidate's quantization # kwargs to the backend engine instantiation. Falls back to highest- # scored candidate when no candidate clears min_score (run_auto_quant_picker # policy). auto_quant_kwargs: dict = {} if auto_quant: from soup_cli.utils.auto_quant import ( default_candidate_order, quant_name_to_vllm_kwargs, run_auto_quant_picker, ) prompts = [ "What is 2 + 2?", "Translate 'hello' to French.", "Name one prime number greater than 10.", ] def _make_eval_fn(_name): def _fn(_prompt): # Pre-bind eval still uses a heuristic — the engine isn't up # yet. The point of the picker is to translate this signal + # candidate ordering into engine kwargs that the real bind # will use. A live in-engine eval refresh remains future work. return ("", True) return _fn candidate_specs = [ (name, _make_eval_fn(name)) for name in default_candidate_order() ] try: picked = run_auto_quant_picker( candidate_specs=candidate_specs, prompts=prompts, ) console.print( f"[green]--auto-quant picked:[/] {picked.name} " f"(score={picked.score:.2f}, latency={picked.latency_ms:.1f}ms)" ) # Forward the chosen quant into the backend engine. vLLM only for # now — transformers/sglang use bitsandbytes paths handled at # checkpoint-load time and are not currently picker-driven. if backend == "vllm": from rich.markup import escape auto_quant_kwargs = quant_name_to_vllm_kwargs(picked.name) if auto_quant_kwargs: console.print( "[green]--auto-quant binding vLLM with:[/] " + escape(repr(auto_quant_kwargs)) ) except ValueError as exc: from rich.markup import escape as _esc console.print(f"[yellow]--auto-quant: {_esc(str(exc))}[/]") # Validate trace endpoint early if trace and trace_endpoint: from soup_cli.utils.tracing import validate_otlp_endpoint try: validate_otlp_endpoint(trace_endpoint) except ValueError as exc: console.print(f"[red]{exc}[/]") raise typer.Exit(1) if backend == "vllm": if speculative_model: console.print( f"[green]Speculative decoding enabled:[/] draft={speculative_model}, " f"tokens={num_speculative_tokens}" ) if prefix_cache: console.print("[green]Prefix caching enabled.[/]") app = _serve_vllm( model_path=model_path, base_model=base_model, is_adapter=is_adapter, max_tokens_default=max_tokens_default, tensor_parallel=tensor_parallel, gpu_memory_utilization=gpu_memory_utilization, speculative_model=speculative_model, num_speculative_tokens=num_speculative_tokens, enable_prefix_caching=prefix_cache, quantization=auto_quant_kwargs.get("quantization"), ) elif backend == "sglang": app = _serve_sglang( model_path=model_path, base_model=base_model, is_adapter=is_adapter, max_tokens_default=max_tokens_default, tensor_parallel=tensor_parallel, gpu_memory_utilization=gpu_memory_utilization, ) else: # Transformers backend (original). # v0.36.0 Part B: --trust-remote-code default-deny. from soup_cli.utils.trust_remote import ( model_requires_trust_remote_code, resolve_trust_remote_code, ) probe_target = base_model or str(model_path) requires = model_requires_trust_remote_code(str(model_path)) or False resolved_trust = resolve_trust_remote_code( probe_target, requested=trust_remote_code, console=console, requires_remote_code=requires, ) model_obj, tokenizer = _load_model( model_path=str(model_path), base_model=base_model, is_adapter=is_adapter, device=device, trust_remote_code=resolved_trust, ) console.print("[bold green]Model loaded![/]") # Load draft model for speculative decoding (transformers backend) draft_model = None if speculative_model: console.print( Panel( f"[bold yellow]WARNING:[/] Loading draft model: " f"[bold]{speculative_model}[/]\n" "If this model contains custom code, it will execute " "on this machine.\n" "Only use models you trust.", title="Speculative Decoding", border_style="yellow", ) ) draft_model = _load_draft_model(speculative_model, device) console.print( f"[green]Speculative decoding enabled:[/] draft={speculative_model}, " f"tokens={num_speculative_tokens}" ) if speculative_model: console.print( "[yellow]Note: streaming with speculative decoding on the " "transformers backend generates the full response before " "streaming begins. Use --backend vllm for true streaming " "with speculative decoding.[/]" ) # Build structured-output constraint from soup_cli.utils.paths import is_under_cwd from soup_cli.utils.structured_output import build_constraint schema_obj = None if json_schema: import json as _json schema_path = Path(json_schema) if not is_under_cwd(schema_path): console.print( f"[red]JSON schema path must stay under the current " f"working directory: {json_schema}[/]" ) raise typer.Exit(1) if not schema_path.exists(): console.print(f"[red]JSON schema file not found: {json_schema}[/]") raise typer.Exit(1) try: schema_obj = _json.loads(schema_path.read_text(encoding="utf-8")) except (OSError, ValueError) as exc: console.print(f"[red]Failed to read JSON schema: {exc}[/]") raise typer.Exit(1) try: constraint = build_constraint( structured_mode, schema_obj, regex_pattern ) except ValueError as exc: console.print(f"[red]{exc}[/]") raise typer.Exit(1) # Build tracer (no-op if SDK missing or disabled) from soup_cli.utils.tracing import build_tracer tracer = build_tracer(enabled=trace, endpoint=trace_endpoint) # v0.40.3 (#33 (b)) — passive request log. trace_log_writer = None if trace_log is not None: from soup_cli.monitoring.trace_logger import TraceLogWriter try: trace_log_writer = TraceLogWriter( trace_log, cap_mb=trace_log_cap_mb, ) except (TypeError, ValueError) as exc: from rich.markup import escape as _escape console.print(f"[red]--trace-log:[/] {_escape(str(exc))}") raise typer.Exit(1) from exc console.print( f"[green]Request trace log:[/] {trace_log_writer.path} " f"(cap {trace_log_cap_mb} MB)" ) # v0.53.9 #98 — validate reasoning parser name once at startup. resolved_reasoning_parser: Optional[str] = None if reasoning_parser: from soup_cli.utils.reasoning_parser import validate_parser_name try: resolved_reasoning_parser = validate_parser_name(reasoning_parser) except (TypeError, ValueError) as exc: console.print(f"[red]--reasoning-parser:[/] {exc}") raise typer.Exit(1) from exc app = _create_app( model_obj=model_obj, tokenizer=tokenizer, device=device, model_name=str(model_path.name), max_tokens_default=max_tokens_default, draft_model=draft_model, num_speculative_tokens=num_speculative_tokens, adapter_map=adapter_map if adapter_map else None, output_constraint=constraint, enable_dashboard=dashboard, tracer=tracer, trace_log_writer=trace_log_writer, reasoning_parser=resolved_reasoning_parser, ) console.print( Panel( f"URL: [bold]http://{host}:{port}[/]\n" f"Backend: [bold]{backend_label}[/]\n" f"Endpoints: [bold]/v1/chat/completions[/], [bold]/v1/models[/], [bold]/health[/]\n\n" f"Example:\n" f" curl http://localhost:{port}/v1/chat/completions \\\n" f' -H "Content-Type: application/json" \\\n' f" -d '{{" f'"model": "{model_path.name}", ' f'"messages": [{{"role": "user", "content": "Hello!"}}]' f"}}'\n\n" f"Press [bold]Ctrl+C[/] to stop.", title="[bold green]Server Ready[/]", ) ) import uvicorn uvicorn.run(app, host=host, port=port, log_level="warning") def _serve_vllm( model_path: Path, base_model: Optional[str], is_adapter: bool, max_tokens_default: int, tensor_parallel: int, gpu_memory_utilization: float, speculative_model: Optional[str] = None, num_speculative_tokens: int = 5, enable_prefix_caching: bool = False, quantization: Optional[str] = None, ): """Set up vLLM engine and create FastAPI app.""" from soup_cli.utils.vllm import create_vllm_app, create_vllm_engine console.print("[dim]Initializing vLLM engine...[/]") engine, engine_model_name = create_vllm_engine( model_path=str(model_path), base_model=base_model, is_adapter=is_adapter, tensor_parallel_size=tensor_parallel, gpu_memory_utilization=gpu_memory_utilization, speculative_model=speculative_model, num_speculative_tokens=num_speculative_tokens, enable_prefix_caching=enable_prefix_caching, quantization=quantization, ) console.print("[bold green]vLLM engine ready![/]") adapter_path = str(model_path) if is_adapter else None app = create_vllm_app( engine=engine, engine_model_name=engine_model_name, model_name=str(model_path.name), adapter_path=adapter_path, max_tokens_default=max_tokens_default, ) return app def _serve_sglang( model_path: Path, base_model: Optional[str], is_adapter: bool, max_tokens_default: int, tensor_parallel: int, gpu_memory_utilization: float, ): """Set up SGLang runtime and create FastAPI app.""" from soup_cli.utils.sglang import create_sglang_app, create_sglang_runtime console.print( Panel( f"[bold yellow]WARNING:[/] Loading model via SGLang: " f"[bold]{model_path}[/]\n" "SGLang loads models with trust_remote_code enabled.\n" "If this model contains custom code, it will execute " "on this machine.\nOnly use models you trust.", title="SGLang Runtime", border_style="yellow", ) ) console.print("[dim]Initializing SGLang runtime...[/]") runtime, runtime_model_name = create_sglang_runtime( model_path=str(model_path), base_model=base_model, is_adapter=is_adapter, tensor_parallel_size=tensor_parallel, mem_fraction_static=gpu_memory_utilization, ) console.print("[bold green]SGLang runtime ready![/]") app = create_sglang_app( runtime=runtime, runtime_model_name=runtime_model_name, model_name=str(model_path.name), max_tokens_default=max_tokens_default, ) return app def _detect_base_model(adapter_config_path: Path) -> Optional[str]: """Read base_model_name_or_path from adapter_config.json.""" try: with open(adapter_config_path, encoding="utf-8") as f: config = json.load(f) return config.get("base_model_name_or_path") except (json.JSONDecodeError, OSError): return None def _load_model( model_path: str, base_model: Optional[str], is_adapter: bool, device: str, trust_remote_code: bool = False, ): """Load model and tokenizer.""" import torch from transformers import AutoModelForCausalLM, AutoTokenizer console.print("[dim]Loading tokenizer...[/]") tokenizer = AutoTokenizer.from_pretrained( model_path, trust_remote_code=trust_remote_code ) if tokenizer.pad_token is None: tokenizer.pad_token = tokenizer.eos_token if is_adapter: from peft import PeftModel console.print(f"[dim]Loading base model: {base_model}...[/]") base = AutoModelForCausalLM.from_pretrained( base_model, trust_remote_code=trust_remote_code, device_map="auto", dtype=torch.float16, ) console.print(f"[dim]Loading LoRA adapter: {model_path}...[/]") model_obj = PeftModel.from_pretrained(base, model_path) else: console.print(f"[dim]Loading model: {model_path}...[/]") model_obj = AutoModelForCausalLM.from_pretrained( model_path, trust_remote_code=trust_remote_code, device_map="auto", dtype=torch.float16, ) model_obj.eval() return model_obj, tokenizer def _load_draft_model(speculative_model: str, device: str): """Load a smaller draft model for speculative decoding.""" import re import torch from transformers import AutoModelForCausalLM # SSRF protection: block URL-based model paths if re.match(r'^https?://', speculative_model): console.print( "[red]Speculative model must be a local path or HuggingFace model ID, " "not a URL.[/]" ) raise typer.Exit(1) console.print(f"[dim]Loading draft model: {speculative_model}...[/]") draft = AutoModelForCausalLM.from_pretrained( speculative_model, device_map="auto" if device != "cpu" else "cpu", dtype=torch.float16 if device != "cpu" else torch.float32, ) draft.eval() return draft def _generate_response( model, tokenizer, messages: list[dict], max_tokens: int = 512, temperature: float = 0.7, top_p: float = 0.9, stream: bool = False, assistant_model=None, num_assistant_tokens: int = 5, logits_processor=None, ngram_config: Any = None, ): """Generate a response from the model.""" import torch # Apply chat template 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"] = top_p if assistant_model is not None: gen_kwargs["assistant_model"] = assistant_model gen_kwargs["num_assistant_tokens"] = num_assistant_tokens # v0.33.0 #53 — structured-output LogitsProcessor list (may be empty). if logits_processor: gen_kwargs["logits_processor"] = logits_processor # v0.53.6 #104 — n-gram speculative decoding (transformers backend). # Mutually exclusive with a real draft `assistant_model`. if ngram_config is not None and assistant_model is None: # HF Transformers >= 4.38 supports prompt-lookup decoding via # `prompt_lookup_num_tokens`. We expose `num_draft_tokens` as # the user-facing knob; n-gram size + prompt_lookup_max are # validated upstream by `validate_ngram_config`. try: gen_kwargs["prompt_lookup_num_tokens"] = int( ngram_config.num_draft_tokens ) except (TypeError, AttributeError): # Schema gate at construction time enforces shape; this # is defence-in-depth. pass outputs = model.generate(**gen_kwargs) new_tokens = outputs[0][input_ids.shape[1]:] response = tokenizer.decode(new_tokens, skip_special_tokens=True).strip() prompt_tokens = input_ids.shape[1] completion_tokens = len(new_tokens) return response, prompt_tokens, completion_tokens def _create_app( model_obj, tokenizer, device: str, model_name: str, max_tokens_default: int, draft_model=None, num_speculative_tokens: int = 5, adapter_map: Optional[Dict[str, str]] = None, output_constraint: Optional[Dict] = None, enable_dashboard: bool = False, tracer=None, trace_log_writer=None, ngram_config: Any = None, web_search_config: Any = None, web_search_backend: Any = None, auth_token: Optional[str] = None, reasoning_parser: Optional[str] = None, ): """Create the FastAPI application with OpenAI-compatible endpoints. Args: auth_token: optional Bearer-token gate for the v0.53.7 tool endpoints (``/v1/tools/python`` + ``/v1/tools/web_search``). When ``None`` (default), endpoints inherit the server's loopback-only CORS trust boundary. When set, callers must supply ``Authorization: Bearer ``. """ import threading as _threading from fastapi import FastAPI, Header, HTTPException from fastapi import Path as FPath from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse from pydantic import BaseModel as PydanticBaseModel from pydantic import Field def _check_tool_auth(authorization: Optional[str]) -> None: """v0.53.7 H-A: gate tool endpoints when ``auth_token`` is set.""" if not auth_token: return expected = f"Bearer {auth_token}" if not authorization or authorization != expected: raise HTTPException( status_code=401, detail="Invalid or missing bearer token" ) from soup_cli.utils.metrics import ServerMetrics app = FastAPI(title="Soup Inference Server", version="1.0.0") # Loopback-only CORS: the inference server hosts state-mutating POST # endpoints (activate/deactivate adapter) without auth, so wildcard CORS # would let any browser page swap the active adapter. Loopback origins # cover the curl / same-host IDE extension cases. app.add_middleware( CORSMiddleware, allow_origin_regex=r"^https?://(localhost|127\.0\.0\.1)(:\d+)?$", allow_methods=["GET", "POST", "OPTIONS"], allow_headers=["*"], ) # Shared metrics bucket — always created so /metrics works whether or # not --dashboard is enabled. metrics = ServerMetrics() # Active adapter name (None = base model). Protected by a lock because # FastAPI runs sync handlers in a threadpool. active_state: Dict[str, Optional[str]] = {"active": None} active_lock = _threading.Lock() # --- Request/Response models --- class ChatMessage(PydanticBaseModel): role: str content: str class ChatCompletionRequest(PydanticBaseModel): model: str = model_name messages: list[ChatMessage] temperature: float = Field(default=0.7, ge=0.0, le=2.0) top_p: float = Field(default=0.9, ge=0.0, le=1.0) max_tokens: Optional[int] = Field(default=None, ge=1, le=16384) stream: bool = False adapter: Optional[str] = Field( default=None, description="Adapter name to use (from --adapters flag).", ) # Resolved adapter map (name → path) _adapter_map = adapter_map or {} # --- Endpoints --- def _active_snapshot() -> Optional[str]: with active_lock: return active_state["active"] @app.get("/health") def health(): return { "status": "ok", "model": model_name, "device": device, "active_adapter": _active_snapshot(), } @app.get("/metrics") def metrics_endpoint(): """Dashboard + Prometheus-style JSON scrape.""" return metrics.snapshot() @app.get("/v1/adapters") def list_adapters(): """List loaded LoRA adapters (names only, no paths for security).""" current = _active_snapshot() return { "adapters": [ {"name": name, "active": name == current} for name in _adapter_map ], "active": current, } @app.post("/v1/adapters/activate/{name}") def activate_adapter(name: str = FPath(..., pattern=r"^[a-zA-Z0-9][a-zA-Z0-9\-]*$")): """Hot-swap the active adapter. Name must be in the loaded map.""" if not _adapter_map: raise HTTPException( status_code=404, detail="No adapters loaded." ) if name not in _adapter_map: raise HTTPException( status_code=404, detail="Unknown adapter. Use GET /v1/adapters to list available adapters.", ) with active_lock: active_state["active"] = name return {"active": name, "status": "ok"} @app.post("/v1/adapters/deactivate") def deactivate_adapter(): """Return to base model (clear active adapter).""" with active_lock: active_state["active"] = None return {"active": None, "status": "ok"} @app.get("/v1/models") def list_models(): return { "object": "list", "data": [ { "id": model_name, "object": "model", "owned_by": "soup", } ], } @app.post("/v1/chat/completions") def chat_completions(request: ChatCompletionRequest): # Check adapter selection (from request body) requested_adapter = request.adapter if requested_adapter and _adapter_map: if requested_adapter not in _adapter_map: raise HTTPException( status_code=404, detail="Unknown adapter. Use GET /v1/adapters to list available adapters.", ) elif requested_adapter and not _adapter_map: raise HTTPException( status_code=404, detail="No adapters loaded.", ) messages = [{"role": msg.role, "content": msg.content} for msg in request.messages] max_tokens = request.max_tokens or max_tokens_default if request.stream: stream_started = time.perf_counter() return StreamingResponse( _stream_response( model_obj, tokenizer, messages, max_tokens=max_tokens, temperature=request.temperature, top_p=request.top_p, model_name=model_name, assistant_model=draft_model, num_assistant_tokens=num_speculative_tokens, trace_log_writer=trace_log_writer, started=stream_started, ), media_type="text/event-stream", ) import contextlib as _contextlib started = time.perf_counter() completion_tokens = 0 # ensure defined on error paths for metrics # Use ExitStack so tracer span + track_request both get correct # exception propagation (__exit__ sees exc info, span marked error). with _contextlib.ExitStack() as stack: stack.enter_context(metrics.track_request()) if tracer is not None: stack.enter_context(tracer.start_as_current_span("chat.completion")) try: try: # v0.33.0 #53 — build LogitsProcessor list per request. # Cheap (~us); per-request build keeps the descriptor # mutable via /v1/output_constraint endpoints in future. from soup_cli.utils.structured_output import ( build_logits_processors, ) processors = build_logits_processors( output_constraint, tokenizer, ) response_text, prompt_tokens, completion_tokens = _generate_response( model_obj, tokenizer, messages, max_tokens=max_tokens, temperature=request.temperature, top_p=request.top_p, assistant_model=draft_model, num_assistant_tokens=num_speculative_tokens, logits_processor=processors or None, ngram_config=ngram_config, ) except Exception: logger.exception("Generation error") raise HTTPException(status_code=500, detail="Internal server error") metrics.record_tokens(completion_tokens) # v0.53.9 #98 — strip reasoning-trace blocks if configured. if reasoning_parser is not None: from soup_cli.utils.reasoning_parser import strip_reasoning response_text = strip_reasoning( response_text, reasoning_parser, ) # output_constraint is validated upstream; v0.33.0 #53 wires # it through outlines / lm-format-enforcer into the generate # loop. If neither library is installed, build_logits_processors # returns an empty list and generation runs free-form. pass # v0.40.3 (#33 (b)) — passive request log; never breaks # the request handler on disk / serialisation issues. if trace_log_writer is not None: last_user = next( (m["content"] for m in reversed(messages) if m.get("role") == "user"), "", ) trace_log_writer.record( prompt=str(last_user), response=response_text, latency_ms=(time.perf_counter() - started) * 1000, tokens=completion_tokens, ) return { "id": f"chatcmpl-{uuid.uuid4().hex[:8]}", "object": "chat.completion", "created": int(time.time()), "model": model_name, "choices": [ { "index": 0, "message": { "role": "assistant", "content": response_text, }, "finish_reason": "stop", } ], "usage": { "prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens, "total_tokens": prompt_tokens + completion_tokens, }, } finally: # Always record latency so tail-latency percentiles include # error paths (prevents blind spots on the dashboard). metrics.record_latency((time.perf_counter() - started) * 1000) # ----- v0.53.6 #102 — Anthropic /v1/messages route ----- # Reuses the v0.45.0 utils/anthropic_messages converter + the existing # chat_completions handler. Live on transformers backend only this # release (vLLM /v1/messages tracked for v0.53.7). @app.post("/v1/messages") def anthropic_messages(payload: dict) -> dict: from soup_cli.utils.anthropic_messages import ( from_anthropic, validate_anthropic_payload, ) # v0.53.7 #102: streaming live (Anthropic event shape). wants_stream = isinstance(payload, dict) and bool(payload.get("stream")) try: validate_anthropic_payload(payload) openai_payload = from_anthropic(payload) # Drop the OpenAI-side ``stream`` field — the streaming path is # handled below using the Anthropic event shape, not OpenAI SSE. openai_payload.pop("stream", None) request = ChatCompletionRequest(**openai_payload) except (TypeError, ValueError) as exc: # Security: do not echo internal validator/converter details # to the HTTP body. Log server-side for operator debugging. logger.debug("/v1/messages invalid request: %s", exc) raise HTTPException(status_code=400, detail="Invalid request") except Exception as exc: # noqa: BLE001 — pydantic ValidationError shape logger.debug("/v1/messages pydantic error: %s", exc) raise HTTPException(status_code=400, detail="Invalid request") chat_response = chat_completions(request) # Map OpenAI chat response back to Anthropic shape. text = "" if isinstance(chat_response, dict): try: text = chat_response["choices"][0]["message"]["content"] except (KeyError, IndexError, TypeError): text = "" usage = ( chat_response.get("usage", {}) if isinstance(chat_response, dict) else {} ) msg_id = ( chat_response.get("id", "") if isinstance(chat_response, dict) else "" ) out_model = openai_payload.get("model", model_name) in_tokens = int(usage.get("prompt_tokens", 0) or 0) out_tokens = int(usage.get("completion_tokens", 0) or 0) if wants_stream: # v0.53.7 #102 streaming live — emit Anthropic event-shape SSE. return StreamingResponse( _stream_anthropic_messages( msg_id=msg_id, model=out_model, text=text, input_tokens=in_tokens, output_tokens=out_tokens, ), media_type="text/event-stream", headers={ "Cache-Control": "no-store", "X-Accel-Buffering": "no", }, ) return { "id": msg_id, "type": "message", "role": "assistant", "model": out_model, "content": [{"type": "text", "text": text}], "stop_reason": "end_turn", "usage": { "input_tokens": in_tokens, "output_tokens": out_tokens, }, } # ----- v0.53.6 #103 / v0.53.7 — Server-side tool endpoints (live) ----- # python / bash route through the RLVR sandbox (v0.25.0 + v0.33.0 #21 # OS-level isolation). web_search enforces a domain allowlist; default # is deny-all per the v0.45.0 Part B schema. tool_max_code_len = 64 * 1024 tool_max_query_len = 1024 tool_max_results = 16 @app.post("/v1/tools/python") def tool_python( payload: dict, authorization: Optional[str] = Header(default=None), ) -> dict: _check_tool_auth(authorization) if not isinstance(payload, dict): raise HTTPException(status_code=400, detail="Invalid request") code = payload.get("code") if not isinstance(code, str) or not code: raise HTTPException(status_code=400, detail="Invalid request") if len(code) > tool_max_code_len: raise HTTPException(status_code=400, detail="Invalid request") try: from soup_cli.trainer.rewards import _run_code_sandbox stdout = _run_code_sandbox(code) except Exception as exc: # noqa: BLE001 logger.debug("/v1/tools/python sandbox error: %s", exc) raise HTTPException(status_code=500, detail="Internal server error") return { "stdout": stdout if stdout is not None else "", "stderr": "", "exit_code": 0 if stdout is not None else 1, "timed_out": stdout is None, } @app.post("/v1/tools/bash") def tool_bash(payload: dict) -> dict: # noqa: ARG001 — payload unused on stub # v0.53.7 review-fix C1: bash spawns ``/bin/sh -c`` which escapes # the RLVR sandbox's OS-level isolation (``unshare(CLONE_NEWNET)`` # / macOS ``sandbox-exec``); a caller can reach # ``http://169.254.169.254/...`` from the child shell. Reverted to # 501 until container/namespace work lands in v0.53.9. raise HTTPException( status_code=501, detail=( "Server-side tool 'bash' live execution deferred to " "v0.53.9 — sandbox isolation requires container/namespace " "work." ), ) @app.post("/v1/tools/web_search") def tool_web_search( payload: dict, authorization: Optional[str] = Header(default=None), ) -> dict: _check_tool_auth(authorization) if not isinstance(payload, dict): raise HTTPException(status_code=400, detail="Invalid request") query = payload.get("query") if not isinstance(query, str) or not query: raise HTTPException(status_code=400, detail="Invalid request") if len(query) > tool_max_query_len: raise HTTPException(status_code=400, detail="Invalid request") max_results = payload.get("max_results", 5) if isinstance(max_results, bool) or not isinstance(max_results, int): raise HTTPException(status_code=400, detail="Invalid request") if max_results < 1 or max_results > tool_max_results: raise HTTPException(status_code=400, detail="Invalid request") # Domain allowlist is threaded into the server constructor — when # absent, default to deny-all per the v0.45.0 schema. cfg = getattr(app.state, "web_search_config", None) allowlist: tuple = () if cfg is not None and hasattr(cfg, "domain_allowlist"): allowlist = tuple(cfg.domain_allowlist) if not allowlist: raise HTTPException( status_code=403, detail="web_search disabled (empty domain allowlist)" ) # The actual search backend is operator-configurable — v0.53.7 ships # the security gate + 403 default and a SearXNG-style placeholder # that returns an empty result set when no upstream is configured. # Operators wanting a live search engine can patch # ``app.state.web_search_backend`` with a callable # ``(query, max_results, allowlist) -> list[dict]``. backend = getattr(app.state, "web_search_backend", None) results: list = [] if callable(backend): try: raw_results = backend(query, max_results, allowlist) except Exception as exc: # noqa: BLE001 logger.debug("/v1/tools/web_search backend error: %s", exc) raise HTTPException( status_code=500, detail="Internal server error" ) for r in (raw_results or [])[:max_results]: if isinstance(r, dict) and "url" in r: # Re-check domain allowlist for backend results. url = r.get("url", "") if not isinstance(url, str): continue from urllib.parse import urlparse host = urlparse(url).hostname or "" host = host.lower() allowed = False for entry in allowlist: if entry.startswith("."): if host == entry[1:] or host.endswith(entry): allowed = True break elif host == entry: allowed = True break if allowed: # M-D: strip null bytes from snippet so a backend # cannot inject embedded NULs through to clients. snippet = str(r.get("snippet", "")).replace("\x00", "") results.append( { "url": url, "snippet": snippet[:512], } ) return {"results": results} # Expose dashboard intent + constraint on the app for tests + introspection app.state.enable_dashboard = enable_dashboard app.state.output_constraint = output_constraint app.state.trace_log_writer = trace_log_writer app.state.web_search_config = web_search_config app.state.web_search_backend = web_search_backend return app def _sanitise_sse_field(value: str, *, max_len: int) -> str: """v0.53.7 M-A: strip CR/LF/NUL + cap len before embedding in SSE. SSE wire framing uses ``\\n`` boundaries; a ``\\n`` in a header-derived field would close the data block early and allow a caller-controlled new event to be injected into the stream. """ if not isinstance(value, str): return "" cleaned = value.replace("\r", "").replace("\n", "").replace("\x00", "") return cleaned[:max_len] def _stream_anthropic_messages( *, msg_id: str, model: str, text: str, input_tokens: int, output_tokens: int, ) -> "Generator[str, None, None]": """v0.53.7 #102 — yield Anthropic event-shape SSE frames. Emits the canonical 4-event sequence: - ``message_start``: opens the message envelope. - ``content_block_delta``: one frame per word (best-effort streaming; the underlying handler ran the full generation eagerly). - ``message_delta`` + ``message_stop``: closes the stream. """ import json as _json # M-A: sanitise caller-influenced fields before SSE embedding. msg_id = _sanitise_sse_field(msg_id, max_len=64) model = _sanitise_sse_field(model, max_len=200) def _frame(event_type: str, data: dict) -> str: return f"event: {event_type}\ndata: {_json.dumps(data)}\n\n" yield _frame( "message_start", { "type": "message_start", "message": { "id": msg_id, "type": "message", "role": "assistant", "content": [], "model": model, "stop_reason": None, "usage": { "input_tokens": int(input_tokens), "output_tokens": 0, }, }, }, ) # Stream word-by-word so SSE consumers see incremental progress, even # though the underlying generation is already complete. words = (text or "").split(" ") for idx, word in enumerate(words): chunk_text = word if idx == 0 else f" {word}" yield _frame( "content_block_delta", { "type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": chunk_text}, }, ) yield _frame( "message_delta", { "type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"output_tokens": int(output_tokens)}, }, ) yield _frame("message_stop", {"type": "message_stop"}) def _stream_response( model, tokenizer, messages, max_tokens, temperature, top_p, model_name, assistant_model=None, num_assistant_tokens=5, trace_log_writer=None, started=None, ): """Generator that yields SSE chunks for streaming responses.""" chat_id = f"chatcmpl-{uuid.uuid4().hex[:8]}" created = int(time.time()) # Generate full response (true token-by-token streaming requires TextIteratorStreamer) completion_tokens_for_log = 0 try: response_text, _, completion_tokens_for_log = _generate_response( model, tokenizer, messages, max_tokens=max_tokens, temperature=temperature, top_p=top_p, assistant_model=assistant_model, num_assistant_tokens=num_assistant_tokens, ) except Exception: logger.exception("Stream generation error") yield 'data: {"error": "Internal server error"}\n\n' return # Simulate streaming by sending word-by-word words = response_text.split(" ") for idx, word in enumerate(words): chunk_text = word if idx == 0 else f" {word}" chunk = { "id": chat_id, "object": "chat.completion.chunk", "created": created, "model": model_name, "choices": [ { "index": 0, "delta": {"content": chunk_text}, "finish_reason": None, } ], } yield f"data: {json.dumps(chunk)}\n\n" # Final chunk final_chunk = { "id": chat_id, "object": "chat.completion.chunk", "created": created, "model": model_name, "choices": [ { "index": 0, "delta": {}, "finish_reason": "stop", } ], } yield f"data: {json.dumps(final_chunk)}\n\n" yield "data: [DONE]\n\n" # v0.40.3 (#33 (b)) — passive request log on the streaming path. Latency # measured from the BEFORE-`_generate_response` mark passed in by the # chat_completions handler. Skipped if writer is None or `started` is # missing. Errors swallowed (passive log). if trace_log_writer is not None and started is not None: try: last_user = next( (m["content"] for m in reversed(messages) if m.get("role") == "user"), "", ) trace_log_writer.record( prompt=str(last_user), response=response_text, latency_ms=(time.perf_counter() - started) * 1000, tokens=int(completion_tokens_for_log), extra={"stream": True}, ) except Exception: # noqa: BLE001 — passive log never blocks SSE logger.debug("trace_log streaming record failed", exc_info=True)