"""Shared OpenAI-compatible chat completions client. Used by vllm + llama.cpp. Both servers expose `/v1/chat/completions` with the same request/response shape, so this module is the single point of HTTP contact for both backends. """ from __future__ import annotations import base64 import io import math import os import time from concurrent.futures import ThreadPoolExecutor from typing import List, Optional from PIL import Image from surya.inference.prompts import PROMPT_MAPPING from surya.inference.schema import ( BatchInputItem, BatchOutputItem, GenerationResult, ) from surya.inference.util import detect_repeat_token, scale_to_fit from surya.logging import get_logger from surya.timing import get_current_timing, set_current_timing, timing_span logger = get_logger() def resolve_max_workers(batch_len: int, max_inflight: int) -> int: """Concurrent HTTP workers for a batch: as many as the batch needs, capped by max_inflight so we keep vLLM's sequence slots full without over-queueing.""" return max(1, min(batch_len, max_inflight)) def encode_image_b64(image: Image.Image) -> tuple[str, str]: image_format = os.getenv("SUYA_VLLM_IMAGE_FORMAT", "JPEG").upper() if image_format not in {"JPEG", "PNG"}: raise ValueError("SUYA_VLLM_IMAGE_FORMAT must be JPEG or PNG") buf = io.BytesIO() if image_format == "JPEG": quality = int(os.getenv("SUYA_VLLM_JPEG_QUALITY", "92")) image.save(buf, format="JPEG", quality=quality, subsampling=0) mime_type = "image/jpeg" else: image.save(buf, format="PNG") mime_type = "image/png" view = buf.getbuffer() try: return base64.b64encode(view).decode("ascii"), mime_type finally: view.release() def _build_messages(image: Image.Image, prompt: str): with timing_span("openai_encode_image_b64", image_size=image.size): image_b64, mime_type = encode_image_b64(image) return [ { "role": "user", "content": [ { "type": "image_url", "image_url": {"url": f"data:{mime_type};base64,{image_b64}"}, }, {"type": "text", "text": prompt}, ], } ] def _mean_token_prob(logprobs_content) -> Optional[float]: if not logprobs_content: return None probs = [] for tok in logprobs_content: lp = ( tok.get("logprob") if isinstance(tok, dict) else getattr(tok, "logprob", None) ) if lp is None: continue probs.append(math.exp(lp)) if not probs: return None return sum(probs) / len(probs) def _generate_one( item: BatchInputItem, client, model_name: str, max_tokens_default: int, temperature: float, top_p: float, timeout: float, request_logprobs_default: bool, stream_mode: str = "off", ) -> GenerationResult: prompt = item.prompt or PROMPT_MAPPING[item.prompt_type] with timing_span("openai_scale_image", prompt_type=item.prompt_type, image_size=item.image.size): image = scale_to_fit(item.image) with timing_span("openai_build_messages", prompt_type=item.prompt_type): messages = _build_messages(image, prompt) max_tokens = item.max_tokens or max_tokens_default request_logprobs = item.request_logprobs or request_logprobs_default kwargs = dict( model=model_name, messages=messages, max_tokens=max_tokens, temperature=temperature, top_p=top_p, timeout=timeout, ) if request_logprobs: kwargs["logprobs"] = True # Structured output: prefer OpenAI-standard response_format (works on both # vllm and llama.cpp). Fall back to vllm's extra_body for guided_regex. if item.guided_json is not None: kwargs["response_format"] = { "type": "json_schema", "json_schema": { "name": "structured_output", "schema": item.guided_json, "strict": True, }, } if item.guided_regex is not None: kwargs.setdefault("extra_body", {})["guided_regex"] = item.guided_regex if stream_mode == "off": return _generate_one_nonstream( item, client, kwargs, max_tokens, request_logprobs, stream_mode ) return _generate_one_stream(item, client, kwargs, max_tokens, stream_mode) def _generate_one_nonstream(item, client, kwargs, max_tokens, request_logprobs, stream_mode): try: with timing_span( "openai_chat_completion", prompt_type=item.prompt_type, max_tokens=max_tokens, ): completion = client.chat.completions.create(**kwargs) raw = completion.choices[0].message.content or "" token_count = completion.usage.completion_tokens if completion.usage else 0 with timing_span( "openai_parse_completion", prompt_type=item.prompt_type, token_count=token_count, ): mean_p = None logprobs_content = None if request_logprobs: choice = completion.choices[0] lp = getattr(choice, "logprobs", None) if lp is not None: content = getattr(lp, "content", None) if content is not None: logprobs_content = [ c.model_dump() if hasattr(c, "model_dump") else c for c in content ] mean_p = _mean_token_prob(content) return GenerationResult( raw=raw, token_count=token_count, error=False, mean_token_prob=mean_p, logprobs=logprobs_content, ) except Exception as e: logger.warning(f"Inference error: {e}") return GenerationResult(raw="", token_count=0, error=True) def _generate_one_stream(item, client, kwargs, max_tokens, stream_mode): """Streaming variant of _generate_one. stream_mode="on" — stream only (same retry semantics as non-streaming). stream_mode="abort" — additionally detect token repetition loops in the live stream; on detection close the connection (vLLM aborts the request and frees the slot) and return a partial result flagged repeat=True so the existing retry loop restarts with higher temperature. """ kwargs["stream"] = True kwargs["stream_options"] = {"include_usage": True} try: with timing_span( "openai_chat_completion_stream", prompt_type=item.prompt_type, max_tokens=max_tokens, ): stream = client.chat.completions.create(**kwargs) raw_parts = [] usage = None repeat = False checked_len = 0 for chunk in stream: if not chunk.choices: # Final chunk carrying usage (stream_options include_usage) usage = getattr(chunk, "usage", None) continue delta = chunk.choices[0].delta.content or "" if not delta: continue raw_parts.append(delta) if stream_mode == "abort": text_len = sum(len(p) for p in raw_parts) # Check at most every 30 new chars, only once text is long enough if text_len - checked_len >= 30 and text_len > 150: text = "".join(raw_parts) if detect_repeat_token(text[-1024:]): logger.info( f"stream abort: repeat detected at {text_len} chars " f"(max_tokens={max_tokens}, prompt_type={item.prompt_type})" ) repeat = True break checked_len = text_len close = getattr(stream, "close", None) if close: close() raw = "".join(raw_parts) token_count = usage.completion_tokens if usage else 0 return GenerationResult(raw=raw, token_count=token_count, error=False, repeat=repeat) except Exception as e: logger.warning(f"Inference error (stream): {e}") return GenerationResult(raw="", token_count=0, error=True) def _should_retry( result: GenerationResult, retries: int, max_retries: int, ) -> bool: if retries >= max_retries: return False if result.error: return True if result.repeat: return True has_repeat = detect_repeat_token(result.raw) or ( len(result.raw) > 50 and detect_repeat_token(result.raw, cut_from_end=50) ) return has_repeat def chat_completions_batch( batch: List[BatchInputItem], client, model_name: str, max_tokens_default: int = 2048, temperature: float = 0.0, top_p: float = 0.1, timeout: float = 600.0, max_workers: Optional[int] = None, max_retries: int = 3, request_logprobs_default: bool = True, stream_mode: str = "off", ) -> List[BatchOutputItem]: """Run a batch of items through the chat completions endpoint with concurrent workers.""" if not batch: return [] if max_workers is None: max_workers = min(64, len(batch)) collector = get_current_timing() def _process(item: BatchInputItem) -> BatchOutputItem: if collector is not None: set_current_timing(collector) result = _generate_one( item, client=client, model_name=model_name, max_tokens_default=max_tokens_default, temperature=temperature, top_p=top_p, timeout=timeout, request_logprobs_default=request_logprobs_default, stream_mode=stream_mode, ) retries = 0 while _should_retry(result, retries, max_retries): backoff = 1.5 * (retries + 1) if result.error else 0 if backoff: time.sleep(backoff) retry_temp = min(temperature + 0.2 * (retries + 1), 0.8) retry_top_p = 0.95 if not result.error else top_p logger.info( f"retry {retries + 1}/{max_retries} item={item.metadata} " f"reason={'repeat' if result.repeat else ('error' if result.error else 'detected')} " f"temp={retry_temp:.1f}" ) result = _generate_one( item, client=client, model_name=model_name, max_tokens_default=max_tokens_default, temperature=retry_temp, top_p=retry_top_p, timeout=timeout, request_logprobs_default=request_logprobs_default, stream_mode=stream_mode, ) retries += 1 return BatchOutputItem( raw=result.raw, token_count=result.token_count, error=result.error, mean_token_prob=result.mean_token_prob, logprobs=result.logprobs, metadata=item.metadata, ) with timing_span( "openai_batch_threadpool", item_count=len(batch), max_workers=max_workers, max_retries=max_retries, ): with ThreadPoolExecutor(max_workers=max_workers) as executor: return list(executor.map(_process, batch))