File size: 12,174 Bytes
6bbf552
 
 
 
 
 
 
3780aa5
 
6bbf552
 
 
 
 
 
 
 
a8afc36
6bbf552
 
 
a8afc36
6bbf552
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b483ca7
 
 
6bbf552
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f4b9db0
 
 
6bbf552
f4b9db0
 
 
6bbf552
f4b9db0
6bbf552
 
 
 
 
 
 
 
 
c1939e1
 
6bbf552
c1939e1
6bbf552
 
 
f4b9db0
 
 
 
 
 
 
 
8c897f9
f4b9db0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6bbf552
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3780aa5
6bbf552
f4b9db0
3780aa5
6bbf552
f4b9db0
 
 
6bbf552
f4b9db0
6bbf552
3780aa5
6bbf552
 
 
 
 
 
 
 
 
c1939e1
 
6bbf552
3780aa5
6bbf552
 
f4b9db0
 
 
 
 
 
 
 
8c897f9
f4b9db0
 
 
 
 
 
 
 
 
 
 
 
 
6bbf552
a8afc36
 
 
 
 
 
 
 
6bbf552
 
 
 
 
 
 
 
 
 
a8afc36
 
6bbf552
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8c897f9
 
 
 
 
 
 
 
 
 
 
 
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
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
import json
import os
from dataclasses import dataclass
from importlib.util import find_spec
from typing import Any, Callable
from urllib import request

from zerogpu import gpu


@dataclass(frozen=True)
class LocalChatClient:
    endpoint: str
    model: str
    timeout_seconds: int = 60
    temperature: float = 0.2
    max_tokens: int = 256
    enable_thinking: bool | None = None

    # Complete one chat prompt through an OpenAI-compatible local endpoint.
    def complete(self, system: str, user: str) -> str:
        payload = chat_payload(self.model, system, user, self.temperature, self.max_tokens, self.enable_thinking)
        req = request.Request(
            self.endpoint,
            data=json.dumps(payload).encode("utf-8"),
            headers={"Content-Type": "application/json"},
            method="POST",
        )
        with request.urlopen(req, timeout=self.timeout_seconds) as response:
            return parse_chat_response(json.loads(response.read().decode("utf-8")))


@dataclass(frozen=True)
class LocalJsonChatClient:
    endpoint: str
    model: str
    timeout_seconds: int = 60
    temperature: float = 0.0
    max_tokens: int = 256

    # Complete one chat prompt through a JSON-constrained local endpoint.
    def complete(self, system: str, user: str) -> str:
        payload = json_chat_payload(self.model, system, user, self.temperature, self.max_tokens)
        req = request.Request(
            self.endpoint,
            data=json.dumps(payload).encode("utf-8"),
            headers={"Content-Type": "application/json"},
            method="POST",
        )
        with request.urlopen(req, timeout=self.timeout_seconds) as response:
            return parse_chat_response(json.loads(response.read().decode("utf-8")))


ChatCompleter = LocalChatClient | LocalJsonChatClient


@dataclass(frozen=True)
class LocalCompletionClient:
    endpoint: str
    model: str
    prompt_template: Callable[[str, str], str]
    timeout_seconds: int = 60
    temperature: float = 0.2
    max_tokens: int = 256

    # Complete one prompt through an OpenAI-compatible local completion endpoint.
    def complete(self, system: str, user: str) -> str:
        prompt = self.prompt_template(system, user)
        payload = completion_payload(self.model, prompt, self.temperature, self.max_tokens)
        req = request.Request(
            self.endpoint,
            data=json.dumps(payload).encode("utf-8"),
            headers={"Content-Type": "application/json"},
            method="POST",
        )
        with request.urlopen(req, timeout=self.timeout_seconds) as response:
            return parse_completion_response(json.loads(response.read().decode("utf-8")))


@dataclass(frozen=True)
class NemotronTransformersChatClient:
    model: Any
    tokenizer: Any
    max_new_tokens: int = 256
    temperature: float = 0.2

    # Set the active model/tokenizer globals, then run inference on ZeroGPU.
    # The @gpu worker reads the model from the global (inherited via fork) instead
    # of receiving it as an argument, which a model object cannot survive (pickle).
    def complete(self, system: str, user: str) -> str:
        global _nemotron_model, _nemotron_tokenizer
        _nemotron_model, _nemotron_tokenizer = self.model, self.tokenizer
        return _nemotron_generate(system, user, self.max_new_tokens, self.temperature)

    # Load Nemotron with its required tokenizer chat template (in the main process).
    @classmethod
    def load(
        cls,
        model_path: str,
        max_new_tokens: int = 256,
        temperature: float = 0.2,
    ) -> "NemotronTransformersChatClient":  # pragma: no cover
        from transformers import AutoModelForCausalLM, AutoTokenizer

        # Load on CPU (no device_map="auto"): on ZeroGPU the move to CUDA must
        # happen inside the @gpu call, in _nemotron_generate.
        tokenizer = AutoTokenizer.from_pretrained(model_path)
        model = AutoModelForCausalLM.from_pretrained(model_path, torch_dtype="auto")
        return cls(model.eval(), tokenizer, max_new_tokens=max_new_tokens, temperature=temperature)


_nemotron_model: Any = None
_nemotron_tokenizer: Any = None


# Run Nemotron generation on a ZeroGPU allocation, reading the model from module
# globals so the forked GPU worker inherits it (only strings cross the boundary).
@gpu
def _nemotron_generate(system: str, user: str, max_new_tokens: int, temperature: float) -> str:
    _nemotron_model.to(best_device())
    messages = chat_messages(system, user)
    inputs = _nemotron_tokenizer.apply_chat_template(
        messages,
        tokenize=True,
        add_generation_prompt=True,
        return_tensors="pt",
    )
    outputs = _nemotron_model.generate(
        tensor_to_model_device(inputs, _nemotron_model),
        max_new_tokens=max_new_tokens,
        do_sample=temperature > 0,
        temperature=temperature,
    )
    return decode_generated_text(_nemotron_tokenizer, inputs, outputs)


@dataclass(frozen=True)
class MLXChatClient:
    model: Any
    tokenizer: Any
    prompt_template: Callable[[str, str], str]
    generate_func: Callable[..., str]
    max_tokens: int = 128

    # Complete one prompt through a local MLX model.
    def complete(self, system: str, user: str) -> str:
        prompt = self.prompt_template(system, user)
        return self.generate_func(self.model, self.tokenizer, prompt, verbose=False, max_tokens=self.max_tokens)

    # Load one MLX model for Apple Silicon inference.
    @classmethod
    def load(
        cls,
        model_path: str,
        prompt_template: Callable[[str, str], str],
        max_tokens: int = 128,
    ) -> "MLXChatClient":  # pragma: no cover
        from mlx_lm import generate, load

        model, tokenizer = load(model_path)
        return cls(model, tokenizer, prompt_template, generate, max_tokens=max_tokens)


@dataclass(frozen=True)
class MiniCPMTransformersChatClient:
    model: Any
    tokenizer: Any
    max_new_tokens: int = 512
    temperature: float = 0.7

    # Set the active model/tokenizer globals, then run inference on ZeroGPU.
    # Sampling is on by default so card authoring is not deterministically repetitive.
    def complete(self, system: str, user: str) -> str:
        global _minicpm_model, _minicpm_tokenizer
        _minicpm_model, _minicpm_tokenizer = self.model, self.tokenizer
        return _minicpm_generate(system, user, self.max_new_tokens, self.temperature)

    # Load MiniCPM with trust_remote_code for its custom chat method (main process).
    @classmethod
    def load(cls, model_path: str, max_new_tokens: int = 512, temperature: float = 0.7) -> "MiniCPMTransformersChatClient":  # pragma: no cover
        import torch
        from transformers import AutoModel, AutoTokenizer

        model = AutoModel.from_pretrained(
            model_path,
            trust_remote_code=True,
            attn_implementation="sdpa",
            torch_dtype=local_torch_dtype(torch),
        )
        # Stay on CPU here: on ZeroGPU the GPU only exists inside @gpu calls,
        # so the move to CUDA happens in _minicpm_generate.
        tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True)
        return cls(model.eval(), tokenizer, max_new_tokens=max_new_tokens, temperature=temperature)


_minicpm_model: Any = None
_minicpm_tokenizer: Any = None


# Run MiniCPM's chat() on a ZeroGPU allocation, reading the model from module
# globals so the forked GPU worker inherits it (only strings cross the boundary).
@gpu
def _minicpm_generate(system: str, user: str, max_new_tokens: int, temperature: float) -> str:
    _minicpm_model.to(best_device())
    return str(
        _minicpm_model.chat(
            msgs=[{"role": "user", "content": user}],
            image=None,
            tokenizer=_minicpm_tokenizer,
            system_prompt=system,
            sampling=temperature > 0,
            temperature=temperature,
            max_new_tokens=max_new_tokens,
        )
    )


# Build an OpenAI-compatible chat completion payload.
def chat_payload(
    model: str,
    system: str,
    user: str,
    temperature: float,
    max_tokens: int | None = None,
    enable_thinking: bool | None = None,
) -> dict[str, Any]:
    payload = {
        "model": model,
        "messages": [
            {"role": "system", "content": system},
            {"role": "user", "content": user},
        ],
        "temperature": temperature,
    }
    if max_tokens is not None:
        payload["max_tokens"] = max_tokens
    if enable_thinking is not None:
        payload["chat_template_kwargs"] = {"enable_thinking": enable_thinking}
    return payload


# Build an OpenAI-compatible JSON-constrained chat payload.
def json_chat_payload(model: str, system: str, user: str, temperature: float, max_tokens: int) -> dict[str, Any]:
    payload = chat_payload(model, system, user, temperature, max_tokens)
    payload["response_format"] = {"type": "json_object"}
    return payload


# Build an OpenAI-compatible text completion payload.
def completion_payload(model: str, prompt: str, temperature: float, max_tokens: int) -> dict[str, Any]:
    return {
        "model": model,
        "prompt": prompt,
        "temperature": temperature,
        "max_tokens": max_tokens,
    }


# Build standard system/user chat messages.
def chat_messages(system: str, user: str) -> list[dict[str, str]]:
    return [{"role": "system", "content": system}, {"role": "user", "content": user}]


# Render Nemotron's documented single-turn prompt markers.
def nemotron_prompt(system: str, user: str) -> str:
    return f"<extra_id_0>System\n{system}\n\n<extra_id_1>User\n{user}\n<extra_id_1>Assistant\n"


# Render a text-only MiniCPM prompt for its model.chat API.
def minicpm_text_prompt(system: str, user: str) -> str:
    return f"System:\n{system}\n\nUser:\n{user}"


# Parse text content from an OpenAI-compatible chat response.
def parse_chat_response(raw: dict[str, Any]) -> str:
    return str(raw["choices"][0]["message"]["content"])


# Parse text content from an OpenAI-compatible completion response.
def parse_completion_response(raw: dict[str, Any]) -> str:
    if "content" in raw:
        return str(raw["content"])
    choice = raw["choices"][0]
    if "text" in choice:
        return str(choice["text"])
    return str(choice["message"]["content"])


# Move generated inputs onto the model device when tensors support it.
def tensor_to_model_device(inputs: Any, model: Any) -> Any:
    device = getattr(model, "device", None)
    if device is not None and hasattr(inputs, "to"):
        return inputs.to(device)
    return inputs


# Decode only tokens generated after the input prompt.
def decode_generated_text(tokenizer: Any, inputs: Any, outputs: Any) -> str:
    output = outputs[0]
    prompt_length = token_length(inputs)
    generated = output[prompt_length:]
    return str(tokenizer.decode(generated, skip_special_tokens=True)).strip()


# Return the final token dimension for tensor-like input ids.
def token_length(inputs: Any) -> int:
    if hasattr(inputs, "shape"):
        return int(inputs.shape[-1])
    if inputs and isinstance(inputs[0], list):
        return len(inputs[0])
    return len(inputs)


# Return practical local loading kwargs for causal Transformers models.
def transformers_model_kwargs() -> dict[str, Any]:
    kwargs: dict[str, Any] = {"torch_dtype": "auto"}
    if find_spec("accelerate") is not None:
        kwargs["device_map"] = "auto"
        kwargs["offload_folder"] = os.environ.get("TABRAS_MODEL_OFFLOAD", "/tmp/tabras-model-offload")
    return kwargs


# Return the best dtype available for MiniCPM local inference.
def local_torch_dtype(torch: Any) -> Any:
    if torch.cuda.is_available():
        return torch.bfloat16
    return "auto"


# Return the best available torch device string (CUDA on a Space/Linux GPU,
# MPS on Apple Silicon, else CPU), so inference works wherever it runs.
def best_device() -> str:
    import torch

    if torch.cuda.is_available():
        return "cuda"
    if getattr(torch.backends, "mps", None) is not None and torch.backends.mps.is_available():
        return "mps"
    return "cpu"