Spaces:
Running on Zero
Running on Zero
Download slotread/client.py from infinitylogesh/GUI-Decisions: direct link, hf CLI and curl.
- Browser
- Download file 13.8 kB
-
https://huggingface.co/spaces/infinitylogesh/GUI-Decisions/resolve/main/slotread/client.py
- Command line
-
hf download hf://spaces/infinitylogesh/GUI-Decisions/slotread/client.py
-
curl -L -o client.py https://huggingface.co/spaces/infinitylogesh/GUI-Decisions/resolve/main/slotread/client.py
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}") | |