import time import uuid from typing import Any, Dict, List, Literal, Optional, Union from fastapi import APIRouter, Request from fastapi.responses import JSONResponse from pydantic import BaseModel, ConfigDict from surya.endpoint import service router = APIRouter() class _ImageUrl(BaseModel): model_config = ConfigDict(extra="allow") url: str class _ContentPart(BaseModel): model_config = ConfigDict(extra="allow") type: str image_url: Optional[_ImageUrl] = None text: Optional[str] = None class _ChatMessage(BaseModel): model_config = ConfigDict(extra="allow") role: str content: Union[str, List[_ContentPart]] class ChatCompletionRequest(BaseModel): # The OpenAI SDK merges `extra_body={...}` into the TOP LEVEL of the request # JSON, so the OCR control flags live here rather than nested under a key. model_config = ConfigDict(extra="allow") model: str = "surya-ocr" messages: List[_ChatMessage] stream: Optional[bool] = False skip_text_detection: bool = False recognize_math: bool = False skip_table_detection: bool = False ocr_with_boxes: bool = True mode: Literal["block", "full_page", "table"] = "block" def _openai_error(message: str, err_type: str, status_code: int) -> JSONResponse: return JSONResponse( status_code=status_code, content={"error": {"message": message, "type": err_type, "code": None}}, ) def _build_chat_completion( content: str, model: str, ocr_json: Any, elapsed: Optional[float] ) -> Dict[str, Any]: # Token accounting happens inside vLLM and is not surfaced to this layer, so # usage counts are reported as 0 (prompt_tokens intentionally 0). return { "id": f"chatcmpl-{uuid.uuid4().hex}", "object": "chat.completion", "created": int(time.time()), "model": model, "choices": [ { "index": 0, "finish_reason": "stop", "message": {"role": "assistant", "content": content}, } ], "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}, "surya": {"ocr_text_json": ocr_json, "elapsed_seconds": elapsed}, } @router.post("/v1/chat/completions") def chat_completions(req: ChatCompletionRequest, request: Request): request_id = getattr(request.state, "request_id", "-") if req.stream: return _openai_error( "streaming is not supported by this OCR endpoint", "invalid_request_error", 400, ) try: image = service.extract_last_image(req.messages) except service.OcrRequestError as exc: return _openai_error(str(exc), "invalid_request_error", 400) try: if req.mode == "table": data = service.table_image(image, req.skip_table_detection) else: skip = req.skip_text_detection or req.mode == "full_page" data = service.ocr_via_batcher( image, skip_text_detection=skip, recognize_math=req.recognize_math, request_id=request_id, ) except service.OcrRequestError as exc: return _openai_error(str(exc), "invalid_request_error", 400) except Exception as exc: service._log_exception("chat_completions", request, exc, {}) return _openai_error(str(exc), "internal_error", 500) content = data.get("text_lines", "") or "" ocr_json = data.get("ocr_text_json") if req.ocr_with_boxes else None return _build_chat_completion(content, req.model, ocr_json, data.get("elapsed_seconds"))