| import os |
| import json |
| from typing import Any, Dict, List, Optional, Union |
|
|
| import torch |
| from fastapi import FastAPI, HTTPException |
| from pydantic import BaseModel, Field |
| from huggingface_hub import login |
| from transformers import AutoTokenizer, AutoModelForCausalLM |
|
|
|
|
| MODEL_ID = os.getenv("MODEL_ID", "oleh13/ord-retro-qwen25-15b-merged-fp16") |
|
|
| hf_token = os.getenv("HF_TOKEN") |
| if hf_token: |
| login(token=hf_token) |
|
|
| app = FastAPI( |
| title="ORD Retrosynthesis Model API", |
| version="1.0.0", |
| ) |
|
|
|
|
| class ChatMessage(BaseModel): |
| role: str |
| content: str |
|
|
|
|
| class GenerateRequest(BaseModel): |
| prompt: Optional[str] = None |
| messages: Optional[List[ChatMessage]] = None |
|
|
| max_new_tokens: int = Field(default=1200, ge=1, le=4096) |
| temperature: float = Field(default=0.1, ge=0.0, le=2.0) |
| top_p: float = Field(default=0.9, ge=0.0, le=1.0) |
| repetition_penalty: float = Field(default=1.05, ge=0.5, le=2.0) |
| do_sample: bool = True |
|
|
| return_json_only: bool = True |
|
|
|
|
| class GenerateResponse(BaseModel): |
| text: str |
| parsed_json: Optional[Union[Dict[str, Any], List[Any]]] = None |
| raw_output: str |
| model_id: str |
|
|
|
|
| def extract_json_object(text: str) -> str: |
| text = text.strip() |
|
|
| first_obj = text.find("{") |
| last_obj = text.rfind("}") |
|
|
| first_arr = text.find("[") |
| last_arr = text.rfind("]") |
|
|
| obj_valid = first_obj != -1 and last_obj != -1 and last_obj > first_obj |
| arr_valid = first_arr != -1 and last_arr != -1 and last_arr > first_arr |
|
|
| if obj_valid and arr_valid: |
| if first_obj < first_arr: |
| return text[first_obj:last_obj + 1] |
| return text[first_arr:last_arr + 1] |
|
|
| if obj_valid: |
| return text[first_obj:last_obj + 1] |
|
|
| if arr_valid: |
| return text[first_arr:last_arr + 1] |
|
|
| raise ValueError("No JSON object or array found in model output.") |
|
|
|
|
| print(f"Loading tokenizer: {MODEL_ID}") |
| tokenizer = AutoTokenizer.from_pretrained( |
| MODEL_ID, |
| trust_remote_code=True, |
| ) |
|
|
| if tokenizer.pad_token is None: |
| tokenizer.pad_token = tokenizer.eos_token |
|
|
|
|
| print(f"Loading model: {MODEL_ID}") |
|
|
| if torch.cuda.is_available(): |
| torch_dtype = torch.bfloat16 |
| device_map = "auto" |
| else: |
| torch_dtype = torch.float32 |
| device_map = "cpu" |
|
|
| model = AutoModelForCausalLM.from_pretrained( |
| MODEL_ID, |
| torch_dtype=torch_dtype, |
| device_map=device_map, |
| trust_remote_code=True, |
| low_cpu_mem_usage=True, |
| ) |
|
|
| model.eval() |
|
|
| print("Model loaded.") |
| print("CUDA:", torch.cuda.is_available()) |
|
|
|
|
| @app.get("/") |
| def root(): |
| return { |
| "status": "ok", |
| "model_id": MODEL_ID, |
| "cuda": torch.cuda.is_available(), |
| } |
|
|
|
|
| @app.get("/health") |
| def health(): |
| return { |
| "status": "ok", |
| "model_id": MODEL_ID, |
| "cuda": torch.cuda.is_available(), |
| } |
|
|
|
|
| @app.post("/chat/completions", response_model=GenerateResponse) |
| def generate(req: GenerateRequest): |
| if req.messages and req.prompt: |
| raise HTTPException( |
| status_code=400, |
| detail="Send either 'prompt' or 'messages', not both.", |
| ) |
|
|
| if not req.messages and not req.prompt: |
| raise HTTPException( |
| status_code=400, |
| detail="Send either 'prompt' or 'messages'.", |
| ) |
|
|
| if req.messages: |
| messages = [m.model_dump() for m in req.messages] |
|
|
| prompt_text = tokenizer.apply_chat_template( |
| messages, |
| tokenize=False, |
| add_generation_prompt=True, |
| ) |
| else: |
| prompt_text = req.prompt |
|
|
| inputs = tokenizer( |
| [prompt_text], |
| return_tensors="pt", |
| ) |
|
|
| if torch.cuda.is_available(): |
| inputs = {k: v.to(model.device) for k, v in inputs.items()} |
|
|
| with torch.no_grad(): |
| outputs = model.generate( |
| **inputs, |
| max_new_tokens=req.max_new_tokens, |
| temperature=req.temperature, |
| top_p=req.top_p, |
| repetition_penalty=req.repetition_penalty, |
| do_sample=req.do_sample, |
| eos_token_id=tokenizer.eos_token_id, |
| pad_token_id=tokenizer.pad_token_id, |
| ) |
|
|
| raw_output = tokenizer.decode( |
| outputs[0][inputs["input_ids"].shape[-1]:], |
| skip_special_tokens=True, |
| ).strip() |
|
|
| parsed_json = None |
| final_text = raw_output |
|
|
| if req.return_json_only: |
| try: |
| json_text = extract_json_object(raw_output) |
| parsed_json = json.loads(json_text) |
| final_text = json.dumps(parsed_json, ensure_ascii=False, indent=2) |
| except Exception as exc: |
| raise HTTPException( |
| status_code=422, |
| detail={ |
| "message": "Model did not return valid JSON.", |
| "error": str(exc), |
| "raw_output": raw_output, |
| }, |
| ) |
|
|
| return GenerateResponse( |
| text=final_text, |
| parsed_json=parsed_json, |
| raw_output=raw_output, |
| model_id=MODEL_ID, |
| ) |