File size: 7,584 Bytes
544e392
 
 
 
 
f1fc3a0
544e392
 
 
f1fc3a0
544e392
 
 
 
 
 
 
 
 
 
 
 
 
 
f1fc3a0
544e392
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f1fc3a0
 
 
 
 
 
 
 
 
 
544e392
 
 
f1fc3a0
 
 
 
544e392
 
 
 
 
 
 
 
 
f1fc3a0
 
 
 
 
544e392
 
 
 
f1fc3a0
544e392
 
 
 
 
 
 
 
 
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
"""Minimal OpenAI-compatible server for the PACT L2 decision LLM (default: google/gemma-4-E2B-it).

Run in an env whose transformers supports the model. gemma-4-* needs transformers>=5 -> use the
dedicated venv `<gemma4-venv>`. Loads the
model ONCE, resident, and serves POST /v1/chat/completions matching what
pact/eval_l2_decision.py::call_openai_compatible expects. Runs as a SEPARATE process from the
closed loop (GameMaster 5B in its own env); they talk over HTTP so VRAM/deps don't collide.

    CUDA_VISIBLE_DEVICES=6 GEMMA_PORT=8000 \
      <gemma4-venv>/bin/python pact/gemma_server.py

Then point the loop / eval at it:
    export GEMMA_OPENAI_BASE_URL=http://127.0.0.1:8000/v1
    export GEMMA_MODEL=gemma-4-E2B-it
"""
import base64, json, os, sys, time
from io import BytesIO
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

import torch
from PIL import Image
from transformers import AutoConfig, AutoProcessor

MODEL_ID = os.environ.get("GEMMA_MODEL_PATH", "google/gemma-4-E2B-it")
MODEL_REVISION = MODEL_ID.rstrip("/").split("/")[-1] if "/snapshots/" in MODEL_ID else None
PORT = int(os.environ.get("GEMMA_PORT", "8000"))
LOCAL_ONLY = os.environ.get("HF_HUB_OFFLINE") == "1" or os.environ.get("TRANSFORMERS_OFFLINE") == "1"

print(f"[gemma_server] loading {MODEL_ID} ...", flush=True)
_proc = AutoProcessor.from_pretrained(MODEL_ID, local_files_only=LOCAL_ONLY)
_mtype = AutoConfig.from_pretrained(MODEL_ID, local_files_only=LOCAL_ONLY).model_type
if _mtype == "gemma4":
    from transformers import Gemma4ForConditionalGeneration as _CLS
elif _mtype == "gemma3n":
    from transformers import Gemma3nForConditionalGeneration as _CLS
elif _mtype == "gemma3":
    from transformers import Gemma3ForConditionalGeneration as _CLS
else:
    raise ValueError(
        f"gemma_server expected model_type in {{'gemma4','gemma3n','gemma3'}}, got {_mtype!r} from {MODEL_ID}")
_model = _CLS.from_pretrained(
    MODEL_ID, dtype=torch.bfloat16, device_map="cuda:0", local_files_only=LOCAL_ONLY).eval()
print(f"[gemma_server] loaded ({_mtype}).", flush=True)


def _to_text_content(c):
    if isinstance(c, str):
        return c
    if isinstance(c, list):  # OpenAI multimodal list -> concat text parts
        return "".join(p.get("text", "") for p in c if isinstance(p, dict) and p.get("type") == "text")
    return str(c)


def _image_from_url(url):
    if not isinstance(url, str):
        raise ValueError("image_url.url must be a string")
    if url.startswith("data:image/"):
        _, b64 = url.split(",", 1)
        return Image.open(BytesIO(base64.b64decode(b64))).convert("RGB")
    if url.startswith("file://"):
        return Image.open(url[7:]).convert("RGB")
    if os.path.exists(url):
        return Image.open(url).convert("RGB")
    raise ValueError("only data:image, file://, or local image paths are supported")


def _to_gemma_content(c):
    if isinstance(c, str):
        return [{"type": "text", "text": c}]
    if not isinstance(c, list):
        return [{"type": "text", "text": str(c)}]
    parts = []
    for p in c:
        if not isinstance(p, dict):
            parts.append({"type": "text", "text": str(p)})
            continue
        typ = p.get("type")
        if typ == "text":
            parts.append({"type": "text", "text": str(p.get("text", ""))})
        elif typ == "image_url":
            image_url = p.get("image_url", {})
            url = image_url.get("url") if isinstance(image_url, dict) else image_url
            parts.append({"type": "image", "image": _image_from_url(url)})
        elif typ == "image":
            image = p.get("image")
            if isinstance(image, Image.Image):
                parts.append({"type": "image", "image": image.convert("RGB")})
            else:
                parts.append({"type": "image", "image": _image_from_url(image)})
    return parts


def generate(messages, temperature, max_new_tokens):
    # Gemma chat template has NO system role -> fold system text into the first user turn.
    sys_txt = "\n".join(_to_text_content(m["content"]) for m in messages if m.get("role") == "system")
    turns = []
    for m in messages:
        if m.get("role") == "system":
            continue
        parts = _to_gemma_content(m["content"])
        if m["role"] == "user" and sys_txt:
            if parts and parts[0].get("type") == "text":
                parts[0]["text"] = sys_txt + "\n\n" + parts[0].get("text", "")
            else:
                parts.insert(0, {"type": "text", "text": sys_txt})
            sys_txt = ""
        turns.append({"role": m["role"], "content": parts})
    inputs = _proc.apply_chat_template(
        turns, add_generation_prompt=True, tokenize=True,
        return_dict=True, return_tensors="pt").to(_model.device)
    in_len = inputs["input_ids"].shape[-1]
    with torch.inference_mode():
        out = _model.generate(
            **inputs, max_new_tokens=max_new_tokens, do_sample=temperature > 0,
            temperature=temperature if temperature > 0 else None,
            pad_token_id=_proc.tokenizer.eos_token_id)
    return _proc.tokenizer.decode(out[0][in_len:], skip_special_tokens=True).strip()


class H(BaseHTTPRequestHandler):
    def log_message(self, *a):  # quiet
        pass

    def _send(self, code, obj):
        b = json.dumps(obj).encode()
        try:
            self.send_response(code)
            self.send_header("Content-Type", "application/json")
            self.send_header("Content-Length", str(len(b)))
            self.end_headers()
            self.wfile.write(b)
        except (BrokenPipeError, ConnectionResetError):
            # The caller may time out while generation is still finishing.
            # There is no client left to receive a secondary 500 response.
            return

    def do_GET(self):
        if self.path.rstrip("/") in ("/health", "/v1/models"):
            self._send(200, {
                "status": "ok", "model": MODEL_ID, "model_id": MODEL_ID,
                "model_revision": MODEL_REVISION, "model_type": _mtype,
            })
        else:
            self._send(404, {"error": "not found"})

    def do_POST(self):
        if not self.path.rstrip("/").endswith("/chat/completions"):
            self._send(404, {"error": "not found"})
            return
        try:
            body = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))))
            requested = str(body.get("model", ""))
            requested_key = "".join(c for c in requested.split("/")[-1].lower() if c.isalnum())
            actual_key = "".join(c for c in MODEL_ID.lower() if c.isalnum())
            if not requested_key or requested_key not in actual_key:
                raise ValueError(f"requested model {requested!r}, but this server hosts {MODEL_ID!r}")
            text = generate(body["messages"],
                            float(body.get("temperature", 0.0)),
                            int(body.get("max_tokens", 256)))
            self._send(200, {"id": "cmpl", "object": "chat.completion", "created": int(time.time()),
                             "model": MODEL_ID,
                             "choices": [{"index": 0, "finish_reason": "stop",
                                          "message": {"role": "assistant", "content": text}}]})
        except Exception as e:
            self._send(500, {"error": f"{type(e).__name__}: {e}"})


if __name__ == "__main__":
    print(f"[gemma_server] serving on http://127.0.0.1:{PORT}/v1  (POST /v1/chat/completions)", flush=True)
    ThreadingHTTPServer(("127.0.0.1", PORT), H).serve_forever()