File size: 13,836 Bytes
d0e7c02
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0c5d1aa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d0e7c02
0c5d1aa
 
 
d0e7c02
0c5d1aa
 
 
d0e7c02
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0c5d1aa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d0e7c02
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Reading slots from a served adapter (vLLM; SGLang optional).

A served adapter is described by its run's config.json (written by slotread.train): each task's system
prompt, slot order and label ids. SlotModel turns that into reads.

  read        one request per slot, in parallel: each request's assistant text ends right before that
              slot's placeholder ("action: ___ x:"), max_tokens=1, and vLLM returns the log-probabilities
              of exactly that slot's label ids (logprob_token_ids). The default.
  read(..., lazy=True)  computer use only: action, x and y first (all a click needs); key, scroll or
              swipe-end slots only when the action calls for them
  read_one    every slot from one request's prompt log-probabilities (top-k per position; slower)
  read_sgl    every slot from one SGLang /generate request (logprob_start_len + token_ids_logprob)

Serving (serving/serve_vllm.sh): vLLM caps logprob_token_ids at 128 per request; serving/patch_vllm.sh
raises it to 256 so a 256-bin slot is one request (MAX_LABEL_IDS=256). Run vLLM with
--api-server-count 8: a single API-server process serializes the parallel slot requests.
"""

import base64, io, json, math, os, time, urllib.error, urllib.request
from concurrent.futures import ThreadPoolExecutor

import torch

from .numbers import slots_for
from .labels import Arm, PLACEHOLDER
from .tasks import AgentTask, BoxTask, SegTask

VLLM_URL = os.environ.get("VLLM_URL", f"http://127.0.0.1:{os.environ.get('PORT', 8001)}")
MAX_IDS = int(os.environ.get("MAX_LABEL_IDS", 256))  # 128 on stock vLLM (see patch_vllm.sh)
PL_TOPK = int(os.environ.get("PL_TOPK", 50))  # read_one: prompt log-probabilities kept per position
EXTRA_BODY = json.loads(os.environ.get("EXTRA_BODY", "{}"))  # e.g. {"mm_processor_kwargs": {"max_soft_tokens": 1120}}
SGL_URL = os.environ.get("SGL_URL", "http://127.0.0.1:30000")


def post(body, url=None, path="/v1/chat/completions", tries=2):
    """POST JSON; returns (response dict, ms). HTTP errors come back as {"error": ...}."""
    t = time.perf_counter()
    d = {}
    for _ in range(tries):
        r = urllib.request.Request((url or VLLM_URL) + path, json.dumps(body).encode(),
                                   {"content-type": "application/json"})
        try:
            d = json.load(urllib.request.urlopen(r, timeout=300))
            break
        except urllib.error.HTTPError as e:
            d = {"error": e.read().decode()[:300]}
            if e.code != 500:
                break
    return d, (time.perf_counter() - t) * 1000


def generate(model, url, text, max_tokens=64, think=False, system=None, **extra):
    """A plain chat completion with one image (the vanilla baselines). Returns (text, ms, usage)."""
    msgs = ([{"role": "system", "content": [{"type": "text", "text": system}]}] if system else []) + \
           [{"role": "user", "content": [{"type": "image_url", "image_url": {"url": url}},
                                         {"type": "text", "text": text}]}]
    d, ms = post(dict(model=model, messages=msgs, max_tokens=max_tokens, temperature=0,
                      chat_template_kwargs={"enable_thinking": think}, **extra))
    if "error" in d:
        return "", ms, {}
    return d["choices"][0]["message"].get("content") or "", ms, d.get("usage", {})


CONFIG_NAME = "slotread_config.json"


def load_config(path):
    """A model's slot config: a run folder (config.json, as slotread.train writes it), a folder with
    slotread_config.json (the released models), or a Hugging Face repo id holding slotread_config.json."""
    if os.path.isdir(path):
        for name in (CONFIG_NAME, "config.json"):
            p = os.path.join(path, name)
            if os.path.exists(p):
                c = json.load(open(p))
                if "tasks" in c:
                    return c
        raise FileNotFoundError(f"no {CONFIG_NAME} in {path}")
    from huggingface_hub import hf_hub_download
    return json.load(open(hf_hub_download(path, CONFIG_NAME)))


class SlotModel:
    """A served slot model: `name` as vLLM serves it (the adapter's --lora-modules name, or the merged
    model's --served-model-name), `path` its slot config (run folder, released model folder or HF repo id).
    `text_model`: the served base model that writes text steps (what to type, the answer)."""

    def __init__(self, name, path, text_model=None):
        self.name, self.path, self.text_model = name, path, text_model
        c = load_config(path)
        self.cfg = c
        self.arm = Arm(c["arm"], c["coarse"], c["bins"], c["tokens"], c["fine"])
        self.tasks = {"agent": AgentTask(), "box": BoxTask(), "seg": SegTask(c["rays"])}

    def task_cfg(self, task):
        return self.cfg["tasks"][task]

    def body(self, task, url, user, assistant, **extra):
        msgs = [{"role": "system", "content": [{"type": "text", "text": self.task_cfg(task)["system"]}]},
                {"role": "user", "content": [{"type": "image_url", "image_url": {"url": url}},
                                             {"type": "text", "text": user}]},
                {"role": "assistant", "content": [{"type": "text", "text": assistant}]}]
        return dict(model=self.name, messages=msgs, add_generation_prompt=False, continue_final_message=True,
                    chat_template_kwargs={"enable_thinking": False}, max_tokens=1, temperature=0,
                    logprobs=True, return_tokens_as_token_ids=True, **EXTRA_BODY, **extra)

    def read(self, task, url, user, slots=None, lazy=False):
        """{slot: log-probabilities over its labels} from parallel per-slot requests."""
        if lazy and task == "agent":
            from .data.aguvis import ACTIONS
            lp = self.read(task, url, user, ["action", *slots_for("x", self.arm), *slots_for("y", self.arm)])
            more = {"press": ["key"], "scroll": ["sdir", "samt"],
                    "swipe": slots_for("x2", self.arm) + slots_for("y2", self.arm)}.get(ACTIONS[int(lp["action"].argmax())])
            if more:
                lp.update(self.read(task, url, user, more))
            return lp
        c = self.task_cfg(task)
        order, lab = c["slots"], c["labels"]
        slots = slots or order
        jobs = [(s, lab[s][i:i + MAX_IDS]) for s in slots for i in range(0, len(lab[s]), MAX_IDS)]

        def one(job):
            s, ids = job
            k = order.index(s)
            prefix = " ".join(f"{x}: ___" for x in order[:k]) + (" " if k else "") + f"{s}:"
            d, _ = post(self.body(task, url, user, prefix, top_logprobs=1, logprob_token_ids=ids))
            if "error" in d:
                raise RuntimeError(f"{self.name}/{task}/{s}: {d['error']}")
            top = d["choices"][0]["logprobs"]["content"][0]["top_logprobs"]
            return s, {int(t["token"].split(":")[1]): t["logprob"] for t in top}

        with ThreadPoolExecutor(len(jobs)) as p:
            res = list(p.map(one, jobs))
        got = {}
        for s, v in res:
            got.setdefault(s, {}).update(v)
        return {s: torch.tensor([got[s].get(i, -1e4) for i in lab[s]]) for s in slots}

    def read_one(self, task, url, user):
        """Every slot from one request's prompt log-probabilities: each slot's answer is its own
        placeholder's row. Labels outside the top PL_TOPK get 5 nats below the lowest returned."""
        c = self.task_cfg(task)
        order, lab = c["slots"], c["labels"]
        d, _ = post(self.body(task, url, user, " ".join(f"{s}: ___" for s in order), top_logprobs=1,
                              prompt_logprobs=PL_TOPK))
        if "error" in d:
            raise RuntimeError(f"{self.name}/{task}: {d['error']}")
        pl = d.get("prompt_logprobs") or []
        marks = [i for i, p in enumerate(pl) if p and any(v.get("decoded_token") == PLACEHOLDER for v in p.values())]
        marks = marks[-len(order):]
        if len(marks) != len(order):
            raise RuntimeError(f"{self.name}/{task}: {len(marks)} placeholders for {len(order)} slots")
        out = {}
        for s, m in zip(order, marks):
            vals = {int(k): v["logprob"] for k, v in (pl[m] or {}).items()}
            floor = (min(vals.values()) - 5) if vals else -20.0
            out[s] = torch.tensor([vals.get(i, floor) for i in lab[s]])
        return out

    def read_sgl(self, task, url, user, slots=None, budget=280, processor="nvidia/Gemma-4-31B-IT-NVFP4"):
        """Every requested slot from ONE SGLang /generate request (serving/sglang): the prompt carries
        the whole layout, and SGLang returns, for the last positions only (logprob_start_len), the
        log-probabilities of our label ids only (token_ids_logprob). Gemma 4 only (its image token
        count is computed client-side to find where the slots start)."""
        from PIL import Image
        proc = _processor(processor)
        c = self.task_cfg(task)
        order, lab = c["slots"], c["labels"]
        slots = slots or order
        layout = " ".join(f"{x}: ___" for x in order)
        msgs = [{"role": "system", "content": [{"type": "text", "text": c["system"]}]},
                {"role": "user", "content": [{"type": "image"}, {"type": "text", "text": user}]},
                {"role": "assistant", "content": [{"type": "text", "text": layout}]}]
        text = proc.apply_chat_template(msgs, tokenize=False, add_generation_prompt=False,
                                        continue_final_message=True, enable_thinking=False)
        W, H = Image.open(io.BytesIO(base64.b64decode(url.split(",", 1)[1]))).size
        # Gemma 4 fits the image to the budget in 48 x 48 pixel cells (16 px patches, 3 x 3 pooling),
        # one token per cell, plus 2 boundary tokens
        f = math.sqrt(budget * 9 * 256 / (W * H))
        soft = int(f * H // 48) * int(f * W // 48)
        n_text = len(proc.tokenizer(text, add_special_tokens=False)["input_ids"])
        tail = len(proc.tokenizer(layout, add_special_tokens=False)["input_ids"])
        start = max(0, n_text - 1 + soft + 2 - tail - 4)  # a few positions early is harmless
        ph = proc.tokenizer("x: ___", add_special_tokens=False)["input_ids"][-1]
        body = dict(text=text, image_data=[url], lora_path=self.name, return_logprob=True, logprob_start_len=start,
                    top_logprobs_num=0, token_ids_logprob=sorted({i for x in slots for i in lab[x]}),
                    sampling_params=dict(max_new_tokens=1, temperature=0))
        d, _ = post(body, url=SGL_URL, path="/generate")
        if "error" in d:
            raise RuntimeError(f"{self.name}/{task}: {d['error']}")
        mi = d["meta_info"]
        marks = [i for i, t in enumerate(mi["input_token_logprobs"]) if t[1] == ph][-len(order):]
        if len(marks) != len(order):
            raise RuntimeError(f"{self.name}/{task}: {len(marks)} placeholders for {len(order)} slots")
        out = {}
        for x in slots:
            row = {t[1]: t[0] for t in mi["input_token_ids_logprobs"][marks[order.index(x)]]}
            out[x] = torch.tensor([row.get(i, -1e4) for i in lab[x]])
        return out


    def image_url(self, image):
        """A PIL image as the data URL sent to the server: JPEG at the model's input size by default (the
        fast path; IMG_FMT / IMG_MAX_PX), capped as in training when the config says so (GUI-Owl: 1 MP).
        A string is taken as a URL already."""
        if isinstance(image, str):
            return image
        from .images import cap_image, data_url
        cap = self.cfg.get("image_cap")
        return data_url(cap_image(image.convert("RGB"), cap), max_px=cap) if cap else data_url(image)

    def decide(self, image, instruction, previous="None"):
        """A computer-use step: action and point; the key, scroll or swipe end when the action needs them.
        Reads action, x and y first, then the action's other slots (lazy)."""
        from .data.aguvis import KEYS, SDIR
        t = time.perf_counter()
        lp = self.read("agent", self.image_url(image), agent_user(instruction, previous), lazy=True)
        d = self.tasks["agent"].decode(lp, self.arm)
        if "key" in lp:
            d["key"] = KEYS[int(lp["key"].argmax())]
        if "sdir" in lp:
            d.update(sdir=SDIR[int(lp["sdir"].argmax())], samt=int(lp["samt"].argmax()))
        return dict(d, ms=round((time.perf_counter() - t) * 1000, 1))

    def box(self, image, description):
        """An element's or object's box [x1, y1, x2, y2] in [0, 1]."""
        t = time.perf_counter()
        lp = self.read("box", self.image_url(image), self.tasks["box"].user({"instruction": description}))
        return dict(box=self.tasks["box"].decode(lp, self.arm), ms=round((time.perf_counter() - t) * 1000, 1))

    def write_text(self, image, instruction, action, previous="None", max_tokens=24):
        """The text a type / answer / open step needs, written by the served base model (text_model)."""
        from .agent import TEXT_ASK
        if not self.text_model:
            raise RuntimeError("text steps need the base model: serve it and pass text_model=<its served name>")
        text, _, _ = generate(self.text_model, self.image_url(image),
                              agent_user(instruction, previous) + "\n\n" + TEXT_ASK[action], max_tokens=max_tokens)
        return text.strip().strip('"').split("\n")[0][:120]


_PROC = {}


def _processor(name):
    if name not in _PROC:
        from transformers import AutoProcessor
        _PROC[name] = AutoProcessor.from_pretrained(name)
    return _PROC[name]


def agent_user(instruction, previous="None"):
    """The agent layout's user text (AGUVIS's format)."""
    return ("Please generate the next move according to the UI screenshot, instruction and previous actions.\n\n"
            f"Instruction: {instruction}\n\nPrevious actions:\n{previous}")