GUI-Decisions / slotread /client.py
infinitylogesh's picture
GUI-Decisions Space
0c5d1aa verified
Raw History Blame Contribute Delete
13.8 kB
"""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}")