File size: 4,972 Bytes
f73086f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4b1f5a4
f73086f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
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,
    )