File size: 6,356 Bytes
7d8006d
97eef57
 
55a0d5d
 
97eef57
 
 
55a0d5d
 
97eef57
 
 
 
 
 
 
55a0d5d
97eef57
 
55a0d5d
97eef57
 
55a0d5d
97eef57
 
 
55a0d5d
97eef57
 
55a0d5d
97eef57
 
 
 
 
55a0d5d
97eef57
 
 
 
 
 
 
 
 
 
55a0d5d
97eef57
 
 
 
55a0d5d
97eef57
 
 
 
 
55a0d5d
97eef57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55a0d5d
 
97eef57
 
 
 
 
 
 
 
 
 
55a0d5d
97eef57
 
 
69ac8d0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
import endpoint_preflight
from pathlib import Path
from typing import Any

import torch
from peft import PeftConfig, PeftModel
from transformers import AutoModelForCausalLM, PreTrainedTokenizerFast


class EndpointHandler:
    def __init__(self, path: str = ""):
        repo_dir = Path(path)
        for filename in (
            "adapter_config.json", "tokenizer.json", "tokenizer_config.json"
        ):
            if not (repo_dir / filename).is_file():
                raise FileNotFoundError(f"Missing {filename} in {repo_dir}")

        config = PeftConfig.from_pretrained(
            str(repo_dir), local_files_only=True
        )
        if not config.base_model_name_or_path:
            raise ValueError("The adapter configuration does not identify a base model.")

        # Everything in this loading block belongs INSIDE __init__.
        self.tokenizer = PreTrainedTokenizerFast.from_pretrained(
            str(repo_dir), local_files_only=True
        )
        if not self.tokenizer.chat_template:
            raise ValueError("Include the saved GPT-OSS chat template in the repository.")

        # Support both newer and older GPT-OSS special-token spellings.
        vocab = self.tokenizer.get_vocab()
        spellings = (
            ("<|start|>", "<|channel|>", "<|message|>", "<|return|>"),
            ("<|im_start|>", "<|meta_sep|>", "<|im_sep|>", "<|fim_suffix|>"),
        )
        for start, channel, message, finish in spellings:
            if all(token in vocab for token in (start, channel, message, finish)):
                self.final_marker = self.tokenizer.encode(
                    channel + "final" + message, add_special_tokens=False
                )
                self.turn_end_id = vocab[finish]
                self.boundary_ids = {vocab[start], vocab[channel], vocab[message]}
                break
        else:
            raise ValueError("The saved tokenizer is missing GPT-OSS boundary tokens.")

        for token in ("<|end|>", "<|im_end|>", "<|call|>", "<|ghissue|>"):
            if token in vocab:
                self.boundary_ids.add(vocab[token])
        self.boundary_ids.add(self.turn_end_id)

        # Reuse an existing token for padding; do not add vocabulary entries.
        if self.tokenizer.pad_token_id is None:
            if self.tokenizer.eos_token_id is None:
                raise ValueError("The saved tokenizer has neither padding nor EOS configured.")
            self.tokenizer.pad_token = self.tokenizer.eos_token

        base_model = AutoModelForCausalLM.from_pretrained(
            config.base_model_name_or_path,
            revision=getattr(config, "revision", None),
            torch_dtype="auto",
            device_map="auto",
        )
        self.model = PeftModel.from_pretrained(
            base_model,
            str(repo_dir),
            config=config,
            is_trainable=False,
            local_files_only=True,
        )
        self.model.eval()

    def _final_answer(self, prompt_ids: list[int], new_ids: list[int]) -> str:
        # Fail closed: never return an analysis-only or unfinished response.
        if not new_ids or new_ids[-1] != self.turn_end_id:
            raise RuntimeError(
                "Generation did not finish a complete answer. Shorten the request "
                "or increase max_new_tokens within the endpoint's memory budget."
            )

        marker = self.final_marker
        # Include a short prompt tail in case the final header was prefilled.
        ids = prompt_ids[-len(marker):] + new_ids
        positions = [
            i + len(marker)
            for i in range(len(ids) - len(marker) + 1)
            if ids[i:i + len(marker)] == marker
        ]
        if not positions:
            raise RuntimeError("No GPT-OSS final-answer channel was generated.")

        answer_ids = ids[positions[-1]:-1]
        if any(token in self.boundary_ids for token in answer_ids):
            raise RuntimeError("Unexpected message boundaries inside the final answer.")
        answer = self.tokenizer.decode(
            answer_ids, skip_special_tokens=True, clean_up_tokenization_spaces=False
        ).strip()
        if not answer:
            raise RuntimeError("The model generated an empty final answer.")
        return answer

    def __call__(self, data: dict[str, Any]) -> list[dict[str, str]]:
        if not isinstance(data, dict):
            raise ValueError("The request body must be a JSON object.")
        prompt = data.get("inputs")
        if not isinstance(prompt, str) or not prompt.strip():
            raise ValueError("'inputs' must be a non-empty prompt string.")

        params = data.get("parameters", {})
        if not isinstance(params, dict):
            raise ValueError("'parameters' must be a JSON object.")
        max_new_tokens = params.get("max_new_tokens", 512)
        if type(max_new_tokens) is not int or not 1 <= max_new_tokens <= 1024:
            raise ValueError("max_new_tokens must be an integer from 1 to 1024.")

        # Your backend can construct this prompt from website selections.
        messages = [{"role": "user", "content": prompt}]
        inputs = self.tokenizer.apply_chat_template(
            messages,
            tokenize=True,
            add_generation_prompt=True,
            return_tensors="pt",
            return_dict=True,
        )
        prompt_ids = inputs["input_ids"][0].tolist()
        context_limit = getattr(self.model.config, "max_position_embeddings", None)
        if context_limit and len(prompt_ids) + max_new_tokens > context_limit:
            raise ValueError("The input plus output budget exceeds the model context limit.")
        inputs = inputs.to(self.model.device)

        with torch.inference_mode():
            outputs = self.model.generate(
                input_ids=inputs["input_ids"],
                attention_mask=inputs["attention_mask"],
                max_new_tokens=max_new_tokens,
                do_sample=False,
                num_beams=1,
                num_return_sequences=1,
                return_dict_in_generate=False,
                pad_token_id=self.tokenizer.pad_token_id,
                eos_token_id=self.turn_end_id,
            )

        new_ids = outputs[0][len(prompt_ids):].tolist()
        return [{"generated_text": self._final_answer(prompt_ids, new_ids)}]