From f0cccd8e8441881d4dda68909630893e9a64f24b Mon Sep 17 00:00:00 2001 From: Tianyao Wu Date: Sun, 27 Sep 2026 20:24:10 +0800 Subject: [PATCH 1/5] cua_s1: add a text worker that loads Qwen3.5-4B through Transformers Add a /v1/systemone worker for the Cua-S1 4B 0.2 text adapter in src/models/cua_s1/text/, next to the multimodal worker proposed in #12. It loads the base model and the PEFT adapter directly through Transformers and PEFT, follows the contract in src/models/cua_s1/README.md, and answers choice questions only. Add the fixed input set and tests in tests/cua_s1/ (contract and HTTP tests that need no weights, and tokenizer checks), and recipe/cua_s1/text.md with setup, launch, a parity check against upstream FourBModel and a latency script. Ignore the recipe's weights/ and .venv/ with the same .gitignore lines as #12. Part of #10. Signed-off-by: Tianyao Wu --- .gitignore | 7 + recipe/README.md | 2 +- recipe/cua_s1/bench_text.py | 122 ++++++++ recipe/cua_s1/compare_text_with_upstream.py | 204 +++++++++++++ recipe/cua_s1/requirements-text.txt | 13 + recipe/cua_s1/text.md | 99 ++++++ src/models/cua_s1/README.md | 16 +- src/models/cua_s1/text/THIRD_PARTY_NOTICES.md | 23 ++ src/models/cua_s1/text/adapter.py | 51 ++++ src/models/cua_s1/text/contract.py | 273 +++++++++++++++++ src/models/cua_s1/text/engine.py | 84 +++++ src/models/cua_s1/text/server.py | 234 ++++++++++++++ tests/cua_s1/data/text_inputs.json | 289 ++++++++++++++++++ tests/cua_s1/test_text_adapter.py | 36 +++ tests/cua_s1/test_text_contract.py | 216 +++++++++++++ tests/cua_s1/test_text_server.py | 167 ++++++++++ tests/cua_s1/test_text_tokenizer.py | 45 +++ 17 files changed, 1876 insertions(+), 5 deletions(-) create mode 100644 recipe/cua_s1/bench_text.py create mode 100644 recipe/cua_s1/compare_text_with_upstream.py create mode 100644 recipe/cua_s1/requirements-text.txt create mode 100644 recipe/cua_s1/text.md create mode 100644 src/models/cua_s1/text/THIRD_PARTY_NOTICES.md create mode 100644 src/models/cua_s1/text/adapter.py create mode 100644 src/models/cua_s1/text/contract.py create mode 100644 src/models/cua_s1/text/engine.py create mode 100644 src/models/cua_s1/text/server.py create mode 100644 tests/cua_s1/data/text_inputs.json create mode 100644 tests/cua_s1/test_text_adapter.py create mode 100644 tests/cua_s1/test_text_contract.py create mode 100644 tests/cua_s1/test_text_server.py create mode 100644 tests/cua_s1/test_text_tokenizer.py diff --git a/.gitignore b/.gitignore index ad67955..a2db644 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,10 @@ target # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +# Python workers and local model artifacts +__pycache__/ +.pytest_cache/ +.ruff_cache/ +.venv/ +weights/ diff --git a/recipe/README.md b/recipe/README.md index 1824bc5..7c13f9c 100644 --- a/recipe/README.md +++ b/recipe/README.md @@ -4,4 +4,4 @@ Top-level home for model setup instructions, launch commands, configuration exam Recipes use the frontend, model engines, and GPU backends. Reusable implementation code belongs in those components rather than in recipes. -Status: layout only; no runnable recipes yet. Add commands and supported configurations once they can be validated against an implementation. +Status: [`cua_s1/text.md`](cua_s1/text.md) runs the Cua-S1 4B 0.2 text worker. Add commands and supported configurations once they can be validated against an implementation. diff --git a/recipe/cua_s1/bench_text.py b/recipe/cua_s1/bench_text.py new file mode 100644 index 0000000..f5ee9c8 --- /dev/null +++ b/recipe/cua_s1/bench_text.py @@ -0,0 +1,122 @@ +"""Send the fixed input set to a running worker, directly and through the frontend. + +For each case it checks that the frontend returns the same status, content +type and body bytes as the worker, then measures warm end-to-end latency on +both paths. Warmup requests are sent first and reported separately. Requests +are sequential (concurrency 1). + + python recipe/cua_s1/bench_text.py --direct http://127.0.0.1:8000 \ + --frontend http://127.0.0.1:8080 --warmup 3 --repeat 20 --out results.json +""" + +from __future__ import annotations + +import argparse +import json +import statistics +import time +import urllib.error +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +# Ignore http_proxy and friends: the worker and the frontend are local. +OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({})) + + +def post(url: str, body: bytes, token: str | None) -> tuple[int, str, bytes, float]: + headers = {"Content-Type": "application/json"} + if token: + headers["Authorization"] = f"Bearer {token}" + request = urllib.request.Request(url + "/v1/systemone", data=body, headers=headers) + started = time.perf_counter() + try: + with OPENER.open(request, timeout=120) as response: + data = response.read() + status, ctype = response.status, response.headers.get("content-type", "") + except urllib.error.HTTPError as error: + data, status, ctype = ( + error.read(), + error.code, + error.headers.get("content-type", ""), + ) + return status, ctype, data, (time.perf_counter() - started) * 1000 + + +def pct(values: list[float], q: float) -> float: + ordered = sorted(values) + return ordered[min(len(ordered) - 1, round(q * (len(ordered) - 1)))] + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--direct", required=True, help="worker base URL") + parser.add_argument( + "--frontend", help="frontend base URL; omit to measure the worker only" + ) + parser.add_argument( + "--inputs", default=str(ROOT / "tests/cua_s1/data/text_inputs.json") + ) + parser.add_argument("--warmup", type=int, default=3) + parser.add_argument("--repeat", type=int, default=20) + parser.add_argument("--token", help="bearer token, if the worker requires one") + parser.add_argument("--out") + args = parser.parse_args() + + cases = json.loads(Path(args.inputs).read_text(encoding="utf-8")) + paths = {"direct": args.direct} + if args.frontend: + paths["frontend"] = args.frontend + results, mismatches = {}, 0 + for name, body in cases.items(): + raw = json.dumps(body, ensure_ascii=False).encode() + status, ctype, direct_body, _ = post(args.direct, raw, args.token) + row = {"status": status, "content_type": ctype} + if status == 200: + reply = json.loads(direct_body) + row["answers"], row["input_tokens"] = ( + reply["answers"], + reply["usage"]["input_tokens"], + ) + else: + row["body"] = direct_body.decode("utf-8", "replace")[:500] + if args.frontend: + f_status, f_ctype, f_body, _ = post(args.frontend, raw, args.token) + row["frontend_identical"] = (f_status, f_ctype, f_body) == ( + status, + ctype, + direct_body, + ) + mismatches += not row["frontend_identical"] + for label, url in paths.items(): + warm = [post(url, raw, args.token)[3] for _ in range(args.warmup)] + times = [post(url, raw, args.token)[3] for _ in range(args.repeat)] + row[label] = { + "warmup_ms": [round(t, 2) for t in warm], + "p50_ms": round(statistics.median(times), 2), + "p95_ms": round(pct(times, 0.95), 2), + "min_ms": round(min(times), 2), + "raw_ms": [round(t, 2) for t in times], + } + results[name] = row + line = f"{name}: status {status}, tokens {row.get('input_tokens')}" + for label in paths: + line += ( + f", {label} p50 {row[label]['p50_ms']} ms p95 {row[label]['p95_ms']} ms" + ) + if args.frontend: + line += f", identical {row['frontend_identical']}" + print(line, flush=True) + if args.out: + Path(args.out).write_text( + json.dumps(results, ensure_ascii=False, indent=1) + "\n" + ) + if args.frontend: + print( + f"{len(cases) - mismatches}/{len(cases)} cases byte-identical through the frontend" + ) + return 1 if mismatches else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/recipe/cua_s1/compare_text_with_upstream.py b/recipe/cua_s1/compare_text_with_upstream.py new file mode 100644 index 0000000..8a50a54 --- /dev/null +++ b/recipe/cua_s1/compare_text_with_upstream.py @@ -0,0 +1,204 @@ +"""Compare the Cua-S1 worker with upstream `FourBModel` on the fixed input set. + +Needs a checkout of trycua/cua at the pinned commit (for `cua_s1.four_b` and +the jev-use chooser) and the pinned weights. The worker's model scores every +question first and is freed; then `FourBModel` is loaded with the same device +and dtype and scores the same questions. For every question it checks: + +- prompt token ids: worker vs upstream `build_prompt` plus the chat template; +- probabilities: worker vs `FourBModel.forward`, exact fp32 equality; +- for the two upstream fixtures, also worker vs the chooser's own path + (`S1DecisionModel.score`), matched by option key. + +Upstream `build_prompt` is given the worker's mapped labels, state and goal, +so the id check covers the prompt layout, chat template and tokenizer. The +request mapping itself (escaping, structured values, `null` labels) is +covered by the unit tests and, independently, by the two fixtures. + +Only one model is resident at a time. Both compute logits for every prompt +position, so the longest input (15,446 tokens) needs about 8 GB for logits in +bfloat16 and 15 GB in float32, on top of the weights. + +Run from the repository root: + + python recipe/cua_s1/compare_text_with_upstream.py --upstream ../cua \\ + --base weights/Qwen3.5-4B --adapter weights/cua-s1-4b-0.2/text --device cuda +""" + +from __future__ import annotations + +import argparse +import gc +import json +import sys +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "src")) + +FIXTURES = { + "fixture_positive": "jev-choice-request-v1.json", + "fixture_negative": "jev-choice-negative-v1.json", +} + + +def free(device: str) -> None: + import torch + + gc.collect() + if device.startswith("cuda"): + torch.cuda.empty_cache() + + +def peak_gib(device: str) -> float | None: + import torch + + if not device.startswith("cuda"): + return None + peak = torch.cuda.max_memory_allocated() / 2**30 + torch.cuda.reset_peak_memory_stats() + return round(peak, 2) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "--upstream", required=True, help="trycua/cua checkout at the pinned commit" + ) + parser.add_argument("--base", required=True) + parser.add_argument("--adapter", required=True) + parser.add_argument("--device", default="cuda") + parser.add_argument("--dtype", default="bfloat16") + parser.add_argument( + "--inputs", default=str(ROOT / "tests/cua_s1/data/text_inputs.json") + ) + parser.add_argument("--out", help="write one JSON line per question here") + parser.add_argument( + "--no-tf32", + action="store_true", + help="disable TF32 in cuBLAS and cuDNN (use for fp32 reference runs)", + ) + args = parser.parse_args() + + upstream = Path(args.upstream) + sys.path.insert(0, str(upstream / "libs/cua-s1/python/src")) + sys.path.insert(0, str(upstream / "libs/cua-driver/examples/jev-use/python")) + import torch + + if args.no_tf32: + torch.backends.cuda.matmul.allow_tf32 = False + torch.backends.cudnn.allow_tf32 = False + from cua_s1.four_b import FourBModel, Option, assign_letters, build_prompt + from decision_models import DecisionRequest, S1DecisionModel + + from models.cua_s1.text.contract import ( + ACTION, + APP, + ROLE, + TASK_FAMILY, + map_request, + parse_body, + ) + from models.cua_s1.text.engine import TextEngine + + cases = json.loads(Path(args.inputs).read_text(encoding="utf-8")) + questions = [] + for name, body in cases.items(): + request = map_request(parse_body(json.dumps(body).encode())) + questions += [(name, request, question) for question in request.questions] + + # Pass 1: the worker. + engine = TextEngine(args.base, args.adapter, args.device, args.dtype) + print(f"worker loaded in {engine.load_seconds:.1f} s", flush=True) + worker = {} + for name, request, question in questions: + worker[name, question.name] = ( + engine.prompt_ids(request.state, question), + engine.score(request.state, question).probabilities, + ) + worker_peak = peak_gib(args.device) + del engine + free(args.device) + + # Pass 2: upstream FourBModel, and the chooser for the two fixtures. + started = time.perf_counter() + reference = FourBModel( + base_model=args.base, + lora_adapter_path=args.adapter, + device=args.device, + dtype=args.dtype, + modality="text", + ) + reference.load() + print(f"upstream loaded in {time.perf_counter() - started:.1f} s", flush=True) + fixture_dir = upstream / "libs/cua-driver/examples/jev-use/fixtures" + rows, failures = [], 0 + for name, request, question in questions: + options = [ + Option(element_id=k, role=ROLE, label=label, action=ACTION) + for k, label in zip(question.keys, question.labels, strict=True) + ] + kwargs = dict( + app=APP, + task_family=TASK_FAMILY, + ax_tree=request.state, + modality="text", + goal=question.goal or None, + ) + messages = build_prompt(assign_letters(options), **kwargs) + chat = reference._tokenizer.apply_chat_template( + messages, tokenize=False, add_generation_prompt=True + ) + upstream_ids = reference._tokenizer(chat)["input_ids"] + upstream_p = [r.probability for r in reference.forward(options, **kwargs)] + worker_ids, worker_p = worker[name, question.name] + row = { + "case": name, + "question": question.name, + "options": len(options), + "prompt_tokens": len(worker_ids), + "ids_equal": worker_ids == upstream_ids, + "probs_equal": worker_p == upstream_p, + "max_abs_diff": max( + abs(a - b) for a, b in zip(worker_p, upstream_p, strict=True) + ), + "worker": dict(zip(question.keys, worker_p, strict=True)), + "upstream": dict(zip(question.keys, upstream_p, strict=True)), + } + if name in FIXTURES: + raw = json.loads((fixture_dir / FIXTURES[name]).read_text(encoding="utf-8")) + chooser = ( + S1DecisionModel(reference, modality="text") + .score(DecisionRequest.from_validated(raw)) + .probabilities + ) + row["chooser_equal"] = all( + chooser.get(k) == p for k, p in row["worker"].items() + ) + ok = row["ids_equal"] and row["probs_equal"] and row.get("chooser_equal", True) + failures += not ok + rows.append(row) + print( + f"{'ok ' if ok else 'FAIL'} {name}/{question.name}: {len(options)} options, " + f"{len(worker_ids)} tokens, max |diff| {row['max_abs_diff']:.3g}", + flush=True, + ) + upstream_peak = peak_gib(args.device) + + if args.out: + with open(args.out, "w", encoding="utf-8") as f: + for row in rows: + f.write(json.dumps(row, ensure_ascii=False) + "\n") + if worker_peak is not None: + print(f"peak allocated: worker {worker_peak} GiB, upstream {upstream_peak} GiB") + print( + f"{len(rows) - failures}/{len(rows)} questions identical " + f"(device {args.device}, dtype {args.dtype}, torch {torch.__version__}, " + f"tf32 {'off' if args.no_tf32 else 'default'})" + ) + return 1 if failures else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/recipe/cua_s1/requirements-text.txt b/recipe/cua_s1/requirements-text.txt new file mode 100644 index 0000000..cde47c5 --- /dev/null +++ b/recipe/cua_s1/requirements-text.txt @@ -0,0 +1,13 @@ +# Versions match upstream's `four-b` lock (trycua/cua libs/cua-s1/python/uv.lock +# at 0e75660ce4c2edda519e0c795fa3ad98abf4e76f), which the parity checks rely on. +torch==2.14.0 +transformers==5.17.0 +tokenizers==0.23.2 +peft==0.21.0 +accelerate==1.15.0 +safetensors==0.8.0 +huggingface-hub==1.32.0 +jinja2==3.1.6 +# HTTP serving. +fastapi==0.141.1 +uvicorn==0.54.0 diff --git a/recipe/cua_s1/text.md b/recipe/cua_s1/text.md new file mode 100644 index 0000000..90dd738 --- /dev/null +++ b/recipe/cua_s1/text.md @@ -0,0 +1,99 @@ +# Cua-S1 4B 0.2 text worker + +This recipe runs the Cua-S1 4B 0.2 `text` adapter behind the Rust frontend. The worker lives in [`src/models/cua_s1/text/`](../../src/models/cua_s1/text/), and [`src/models/cua_s1/README.md`](../../src/models/cua_s1/README.md) documents the inference contract and the request mapping. Only `choice` questions are supported. + +Run all commands from the repository root, on Linux with an NVIDIA GPU. + +## Install + +Use Python 3.12. The pinned versions match the upstream reference environment: + +```sh +python3.12 -m venv .venv +.venv/bin/python -m pip install -r recipe/cua_s1/requirements-text.txt +``` + +## Download the weights + +Download the pinned revisions (about 9.5 GB) into `weights/`: + +```sh +.venv/bin/hf download Qwen/Qwen3.5-4B \ + --revision 851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a --local-dir weights/Qwen3.5-4B +.venv/bin/hf download cua-ai/cua-s1-4b-0.2 \ + --revision 16818868b0cc7813808aae4e87b417657046ab79 --local-dir weights/cua-s1-4b-0.2 +``` + +To verify every file against upstream's lock, clone [trycua/cua](https://github.com/trycua/cua) next to this repository, check out `0e75660ce4c2edda519e0c795fa3ad98abf4e76f`, and run: + +```sh +.venv/bin/python ../cua/libs/cua-s1/ci/fetch_pinned_weights.py --dest weights --verify-only +``` + +## Start the worker + +```sh +PYTHONPATH=src .venv/bin/python -m models.cua_s1.text.server \ + --base weights/Qwen3.5-4B --adapter weights/cua-s1-4b-0.2/text \ + --device cuda --dtype bfloat16 --host 127.0.0.1 --port 8000 +``` + +The worker loads the model and runs one warmup decision before it starts listening, so `GET /health` answers only once requests can be served; it then returns `{"status": "ready", "modality": "text", ...}`. The log reports load and warmup times separately. The worker refuses the `multimodal/` adapter and reports the adapter revision that `hf download` recorded. Every flag can also be set through an environment variable: `CUA_S1_BASE`, `CUA_S1_ADAPTER`, `CUA_S1_ADAPTER_REVISION`, `CUA_S1_DEVICE`, `CUA_S1_DTYPE`, `CUA_S1_HOST`, `CUA_S1_PORT`, `CUA_S1_MAX_BODY_BYTES`, `CUA_S1_MAX_QUESTIONS` and `CUA_S1_MAX_PROMPT_TOKENS`. `--adapter-revision` only sets the revision reported in `model` when the download metadata is missing; a value that contradicts the metadata stops the worker. Set `CUA_S1_API_KEY` to require `Authorization: Bearer ` on `/v1/systemone`. + +Oversized requests get `413`: bodies over 4 MiB, more than 64 questions, or a question whose prompt is over 16,384 tokens (`--max-body-bytes`, `--max-questions`, `--max-prompt-tokens`). The worker computes logits for every prompt position, as upstream does, so memory grows with prompt length: serving the 15,446-token test input in bfloat16 peaked at about 21.3 GiB in use on the card. Requests run one at a time, and the frontend gives up after 60 seconds. + +## Start the frontend + +The frontend is in [#2](https://github.com/ThinkFlowLab/system1-omni/pull/2), which is not merged yet. Build it from that pull request's branch: + +```sh +git fetch origin pull/2/head:frontend-pr2 +git worktree add ../system1-omni-frontend frontend-pr2 +(cd ../system1-omni-frontend && cargo build --release --locked) +OMNI_JEV_BIND=127.0.0.1:8080 \ +OMNI_JEV_BACKEND_URL=http://127.0.0.1:8000 \ + ../system1-omni-frontend/target/release/omni-jev +``` + +## Send a request + +```sh +curl http://127.0.0.1:8080/health +curl http://127.0.0.1:8080/v1/systemone \ + -H 'Content-Type: application/json' \ + -d '{"model":"cua-s1-4b-0.2","state":"Dialog: Delete 3 files permanently? Buttons: Delete, Cancel","questions":{"pick":{"type":"choice","instructions":"Keep the files.","criteria":{"delete":"Click Delete","cancel":"Click Cancel"}}}}' +``` + +The answer has the Jev choice shape. On an RTX 6000 Ada in bfloat16, the response is: + +```json +{"model":"cua-ai/cua-s1-4b-0.2@16818868b0cc7813808aae4e87b417657046ab79:text","answers":{"pick":{"type":"choice","choice":"cancel","probabilities":{"delete":0.0024726232513785362,"cancel":0.9975274205207825},"confidence":0.9750249565060322}},"usage":{"input_tokens":153,"output_tokens":0}} +``` + +## Check against upstream + +`compare_text_with_upstream.py` scores the fixed input set (`tests/cua_s1/data/text_inputs.json`) with the worker's model and then with upstream `FourBModel`, one model at a time, and compares the results. It needs the trycua/cua checkout from above and two extra packages for upstream's processor: + +```sh +.venv/bin/python -m pip install torchvision==0.29.0 pillow==11.3.0 +.venv/bin/python recipe/cua_s1/compare_text_with_upstream.py --upstream ../cua \ + --base weights/Qwen3.5-4B --adapter weights/cua-s1-4b-0.2/text --device cuda +``` + +Every question must have identical prompt token ids and identical fp32 probabilities. + +With the worker and the frontend running, `bench_text.py` checks that the frontend returns the same bytes as the worker for every input, then measures warm latency on both paths: + +```sh +.venv/bin/python recipe/cua_s1/bench_text.py --direct http://127.0.0.1:8000 \ + --frontend http://127.0.0.1:8080 --warmup 3 --repeat 20 +``` + +## Tests + +The contract and HTTP tests need neither weights nor a GPU. The tokenizer tests also run when `CUA_S1_BASE` points to the downloaded base model; they read only its tokenizer files: + +```sh +.venv/bin/python -m pip install pytest +CUA_S1_BASE=weights/Qwen3.5-4B PYTHONPATH=src .venv/bin/python -m pytest tests/cua_s1/test_text_*.py +``` diff --git a/src/models/cua_s1/README.md b/src/models/cua_s1/README.md index 6a07260..f0712d2 100644 --- a/src/models/cua_s1/README.md +++ b/src/models/cua_s1/README.md @@ -2,7 +2,15 @@ This directory owns Cua-S1 4B 0.2 ([#10](https://github.com/ThinkFlowLab/system1-omni/issues/10)): request mapping, prompt construction, adapter selection, execution, and the answer-letter readout. This page records the pinned upstream revisions, the inference contract an implementation must match, and how its outputs will be compared with the upstream reference. -Status: planned; nothing is implemented or validated yet. The first target is the `text` adapter on CUDA, starting with a worker that loads the model directly through Hugging Face Transformers and PEFT. The `multimodal` adapter is deferred; see [Not covered yet](#not-covered-yet). +Status: a reference worker for the `text` adapter is in [`text/`](text/). It loads the model directly through Hugging Face Transformers and PEFT and serves `/v1/systemone`; setup and checks are in [`recipe/cua_s1/text.md`](../../../recipe/cua_s1/text.md). A worker for the `multimodal` adapter is proposed in [#12](https://github.com/ThinkFlowLab/system1-omni/pull/12). + +| Path | Contents | +| --- | --- | +| `text/contract.py` | Request validation, the `/v1/systemone` mapping, prompt construction and answers. No torch imports. | +| `text/engine.py` | Model and adapter loading and the answer-letter readout. | +| `text/server.py` | The HTTP worker (`GET /health`, `POST /v1/systemone`). | +| `text/adapter.py` | Finds and checks the local `text` adapter and its downloaded revision. | +| `tests/cua_s1/test_text_*.py` (repository root) | Tests that need neither weights nor a GPU, and tokenizer checks. The fixed input set is `tests/cua_s1/data/text_inputs.json`. | ## Pinned revisions @@ -76,19 +84,19 @@ The response `model` is `cua-ai/cua-s1-4b-0.2@:`, in An error rejects the whole request. Its body is `{"detail": ""}`, as the LAYA worker returns, and the message names the problem. -The status is `400` when the body is not a usable JSON object: invalid JSON or UTF-8, `NaN` or `Infinity`, a lone surrogate such as `\ud800`, nesting too deep to parse, or a key repeated in any object. +The status is `400` when the body is not a usable JSON object: invalid JSON or UTF-8, `NaN`, `Infinity` or a number out of range, a lone surrogate such as `\ud800`, nesting too deep to parse, or a key repeated in any object. The status is `422` when a well-formed request cannot be answered: - a `score` or `noul` question, since the adapters were trained only on closed-option choices; - a question without an `instructions` field (`null` is allowed), or a `choice` with no options or more than 26 options; - a `criteria` value that is a number or a boolean; -- an empty `state`; +- an empty `state` (`""`, `{}` or `[]`); - a `model` other than `cua-s1-4b-0.2`. ## Validation -**Inputs.** The fixed input set is upstream's two checked-in fixtures, converted to `/v1/systemone` requests with the chooser's rendered regions as `state`, plus `/v1/systemone` choice requests that will be checked in with the worker. These cover 1 to 26 options, short and long states, string and structured `state`, `instructions` and `criteria`, `null` criteria, non-ASCII text, and text that spells a special token. Each input is scored once per configuration. +**Inputs.** The fixed input set is upstream's two checked-in fixtures, converted to `/v1/systemone` requests with the chooser's rendered regions as `state`, plus `/v1/systemone` choice requests, in `tests/cua_s1/data/text_inputs.json`. These cover 1 to 26 options, short and long states, string and structured `state`, `instructions` and `criteria`, `null` criteria, non-ASCII text, and text that spells a special token. Each input is scored once per configuration. **Tolerances.** These are declared before any comparison is run: diff --git a/src/models/cua_s1/text/THIRD_PARTY_NOTICES.md b/src/models/cua_s1/text/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..f054d43 --- /dev/null +++ b/src/models/cua_s1/text/THIRD_PARTY_NOTICES.md @@ -0,0 +1,23 @@ +The system message, prompt layout and fixed values in `contract.py`, and the two upstream fixtures converted in `tests/cua_s1/data/text_inputs.json`, come from [trycua/cua](https://github.com/trycua/cua) at `0e75660ce4c2edda519e0c795fa3ad98abf4e76f` under the following license. + +MIT License + +Copyright (c) 2025 Cua AI, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/models/cua_s1/text/adapter.py b/src/models/cua_s1/text/adapter.py new file mode 100644 index 0000000..472e533 --- /dev/null +++ b/src/models/cua_s1/text/adapter.py @@ -0,0 +1,51 @@ +"""Locate and check the local Cua-S1 `text` adapter. No torch imports.""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + + +def text_adapter_dir(adapter_root: str | Path) -> Path: + """Return the `text` adapter directory under the adapter root. + + Accepts the repository root (`/text`) or the `text/` directory + itself, and refuses the `multimodal/` adapter: PEFT only warns about keys + it cannot place, so loading the wrong adapter would otherwise go unnoticed. + """ + root = Path(adapter_root) + path = root / "text" if (root / "text" / "adapter_config.json").exists() else root + config_file = path / "adapter_config.json" + if not config_file.exists(): + raise RuntimeError(f"no adapter_config.json under {root}") + config = json.loads(config_file.read_text()) + if config.get("base_model_name_or_path") != "Qwen/Qwen3.5-4B": + raise RuntimeError(f"{config_file}: base model is not Qwen/Qwen3.5-4B") + if {"linear_fc1", "linear_fc2"} & set(config.get("target_modules") or []): + raise RuntimeError( + f"{config_file}: this is the multimodal adapter, not the text adapter" + ) + return path + + +def downloaded_revision(adapter_root: str | Path) -> str | None: + """The commit that `hf download --local-dir` recorded for the text adapter, if any. + + `hf download` keeps its metadata under the repository root, so this also + looks one level up when `adapter_root` is the `text/` directory itself. + """ + root = Path(adapter_root) + places = [(root, "text/"), (root, "")] + if root.name == "text": + places.insert(0, (root.parent, "text/")) + for base, prefix in places: + cache = base / ".cache" / "huggingface" / "download" + try: + meta = (cache / f"{prefix}adapter_model.safetensors.metadata").read_text() + first = meta.splitlines()[0].strip() + except (OSError, IndexError): + continue + if re.fullmatch(r"[0-9a-f]{40}", first): + return first + return None diff --git a/src/models/cua_s1/text/contract.py b/src/models/cua_s1/text/contract.py new file mode 100644 index 0000000..a0a2d1d --- /dev/null +++ b/src/models/cua_s1/text/contract.py @@ -0,0 +1,273 @@ +"""Request mapping, prompt construction and answers for Cua-S1 4B 0.2. + +This module follows the contract in `src/models/cua_s1/README.md`. It has no +torch or Transformers imports, so it can be tested without weights. +""" + +from __future__ import annotations + +import json +import math +import string +from dataclasses import dataclass +from typing import Any + +MODEL_NAME = "cua-s1-4b-0.2" +ADAPTER_REPO = "cua-ai/cua-s1-4b-0.2" +ADAPTER_REVISION = "16818868b0cc7813808aae4e87b417657046ab79" +BASE_REPO = "Qwen/Qwen3.5-4B" +BASE_REVISION = "851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a" + +LETTERS = string.ascii_uppercase +MAX_OPTIONS = len(LETTERS) + +# The system message, the user message layout and the fixed values below are +# copied from trycua/cua at 0e75660ce4c2edda519e0c795fa3ad98abf4e76f: +# `libs/cua-s1/python/src/cua_s1/four_b.py` (SYSTEM_PROMPT, build_prompt, +# _describe_option) and `libs/cua-driver/examples/jev-use/python/ +# decision_models.py` (S1DecisionModel.score). MIT License, Copyright (c) 2025 +# Cua AI, Inc.; see THIRD_PARTY_NOTICES.md. +SYSTEM_PROMPT = ( + "You are a one-pass computer-use decision model. You are shown the " + "current state of a screen and a fixed, closed list of candidate " + "(element, action) options, each given a single letter. Choose exactly " + "one option: the single best next action to take. Answer with ONLY that " + "option's letter -- no words, no punctuation, no explanation." +) +APP = "Cua Driver" +TASK_FAMILY = "closed-candidate decision" +ROLE = "Decision" +ACTION = "select" + + +class RequestError(ValueError): + """A request the worker rejects. `status` is the HTTP status to return.""" + + def __init__(self, message: str, status: int = 422) -> None: + super().__init__(message) + self.status = status + + +def _object_pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + obj: dict[str, Any] = {} + for key, value in pairs: + if key in obj: + raise RequestError(f"duplicate key {key!r} in a JSON object", status=400) + obj[key] = value + return obj + + +def _reject_constant(name: str) -> Any: + raise RequestError(f"{name} is not valid JSON", status=400) + + +def _finite_float(text: str) -> float: + value = float(text) + if not math.isfinite(value): + raise RequestError(f"number {text} is out of range", status=400) + return value + + +def _check_unicode(value: Any) -> None: + """Reject lone surrogates (for example a `\\ud800` escape): they cannot be + encoded as UTF-8, so they cannot be tokenized or echoed back.""" + if isinstance(value, str): + value.encode("utf-8") + elif isinstance(value, dict): + for key, item in value.items(): + key.encode("utf-8") + _check_unicode(item) + elif isinstance(value, list): + for item in value: + _check_unicode(item) + + +def parse_body(raw: bytes) -> dict[str, Any]: + """Decode a request body, keeping key order and rejecting duplicate keys.""" + try: + text = raw.decode("utf-8") + body = json.loads( + text, + object_pairs_hook=_object_pairs, + parse_constant=_reject_constant, + parse_float=_finite_float, + ) + _check_unicode(body) + except RequestError: + raise + except RecursionError as error: + raise RequestError("request body is nested too deeply", status=400) from error + except UnicodeError as error: + raise RequestError( + "request body must be valid UTF-8 text", status=400 + ) from error + except ValueError as error: + # json.JSONDecodeError, a UTF-8 byte order mark, or an integer too long + # for Python to convert. + raise RequestError("request body must be valid JSON", status=400) from error + if not isinstance(body, dict): + raise RequestError("request body must be a JSON object", status=400) + return body + + +def as_text(value: Any) -> str: + """Render `state` or `instructions` as prompt text. + + A string is used as is; an object or array is serialized the way Python's + `json.dumps(value, ensure_ascii=False)` does. + """ + if isinstance(value, str): + return value + return json.dumps(value, ensure_ascii=False) + + +def escape_label(value: str) -> str: + """Escape an option label the way upstream's chooser does.""" + return json.dumps(value, ensure_ascii=False)[1:-1] + + +@dataclass(frozen=True) +class Question: + """One `choice` question mapped onto the prompt fields.""" + + name: str + goal: str + keys: tuple[str, ...] + labels: tuple[str, ...] + + +@dataclass(frozen=True) +class Request: + state: str + questions: tuple[Question, ...] + + +def _check_json_value(value: Any, where: str, allow_null: bool) -> None: + if value is None: + if not allow_null: + raise RequestError(f"{where} must not be null") + return + if isinstance(value, bool) or isinstance(value, (int, float)): + raise RequestError(f"{where} must be a string, an object or an array") + if not isinstance(value, (str, dict, list)): + raise RequestError(f"{where} must be a string, an object or an array") + + +def map_request(body: dict[str, Any], *, max_questions: int = 64) -> Request: + """Validate a `/v1/systemone` body and map it onto prompt fields.""" + model = body.get("model") + if model != MODEL_NAME: + raise RequestError(f"'model' must be {MODEL_NAME!r}") + + if "state" not in body: + raise RequestError("'state' is required") + state_value = body["state"] + _check_json_value(state_value, "'state'", allow_null=False) + if state_value in ("", {}, []): + raise RequestError("'state' must not be empty") + state = as_text(state_value) + + questions = body.get("questions") + if not isinstance(questions, dict) or not questions: + raise RequestError("'questions' must be a non-empty object") + if len(questions) > max_questions: + raise RequestError( + f"too many questions ({len(questions)} > {max_questions})", status=413 + ) + + # Check every question type before the per-question checks, so a `score` + # or `noul` question anywhere rejects the whole request with that reason. + for name, question in questions.items(): + if not isinstance(question, dict): + raise RequestError(f"question {name!r} must be an object") + kind = question.get("type") + if kind in ("score", "noul"): + raise RequestError( + f"question {name!r}: type {kind!r} is not supported; " + "Cua-S1 4B 0.2 answers 'choice' questions only" + ) + if kind != "choice": + raise RequestError(f"question {name!r}: unknown type {kind!r}") + + mapped = [] + for name, question in questions.items(): + where = f"question {name!r}" + if "instructions" not in question: + raise RequestError(f"{where}: 'instructions' is required") + instructions = question["instructions"] + _check_json_value(instructions, f"{where}: 'instructions'", allow_null=True) + goal = "" if instructions is None else as_text(instructions) + + criteria = question.get("criteria") + if not isinstance(criteria, dict): + raise RequestError(f"{where}: 'criteria' must be an object") + if not criteria: + raise RequestError(f"{where}: 'criteria' must have at least one option") + if len(criteria) > MAX_OPTIONS: + raise RequestError( + f"{where}: {len(criteria)} options; at most {MAX_OPTIONS} are supported" + ) + keys, labels = [], [] + for key, value in criteria.items(): + _check_json_value(value, f"{where}: option {key!r}", allow_null=True) + if value is None: + text = key + else: + text = as_text(value) + keys.append(key) + labels.append(escape_label(text)) + mapped.append( + Question(name=name, goal=goal, keys=tuple(keys), labels=tuple(labels)) + ) + return Request(state=state, questions=tuple(mapped)) + + +def build_messages(state: str, question: Question) -> list[dict[str, str]]: + """Chat messages for one question, matching upstream `build_prompt` (text).""" + option_lines = "\n".join( + f'{letter}. {ROLE} "{label}" -> {ACTION}' + for letter, label in zip(LETTERS, question.labels, strict=False) + ) + user = ( + (f"Goal: {question.goal}\n\n" if question.goal else "") + + f"App: {APP}\nTask family: {TASK_FAMILY}\n\n" + + f"Accessibility tree:\n{state}\n\n" + + f"Options:\n{option_lines}\n\nAnswer with a single letter." + ) + return [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": user}, + ] + + +def confidence(probabilities: list[float]) -> float: + """Normalized entropy, `1 - H(p) / ln(n)`, as the LAYA worker reports it.""" + n = len(probabilities) + if n < 2: + return 1.0 + entropy = -sum(p * math.log(min(max(p, 1e-12), 1.0)) for p in probabilities) + return min(max(1.0 - entropy / math.log(n), 0.0), 1.0) + + +def answer(question: Question, probabilities: list[float]) -> dict[str, Any]: + """The Jev choice answer. Ties go to the earliest option.""" + if len(probabilities) != len(question.keys) or not all( + math.isfinite(p) and 0.0 <= p <= 1.0 for p in probabilities + ): + raise ValueError(f"model returned invalid probabilities: {probabilities}") + if not math.isclose(sum(probabilities), 1.0, abs_tol=1e-5): + raise ValueError(f"model probabilities do not sum to one: {probabilities}") + best = 0 + for index, p in enumerate(probabilities): + if p > probabilities[best]: + best = index + return { + "type": "choice", + "choice": question.keys[best], + "probabilities": dict(zip(question.keys, probabilities, strict=True)), + "confidence": confidence(probabilities), + } + + +def model_identity(revision: str = ADAPTER_REVISION, modality: str = "text") -> str: + return f"{ADAPTER_REPO}@{revision}:{modality}" diff --git a/src/models/cua_s1/text/engine.py b/src/models/cua_s1/text/engine.py new file mode 100644 index 0000000..cdcc351 --- /dev/null +++ b/src/models/cua_s1/text/engine.py @@ -0,0 +1,84 @@ +"""Load Qwen3.5-4B with the Cua-S1 `text` adapter and score one prompt. + +The calls mirror upstream `cua_s1.four_b.FourBModel` (text modality): the same +model class, an unmerged PEFT adapter, the chat template with its default +generation prompt, full logits, and a fp32 softmax over the letter logits at +the last position. Keeping them the same is what makes the worker's +probabilities bitwise identical to the reference in the same environment. +""" + +from __future__ import annotations + +import time +from dataclasses import dataclass + +import torch +from peft import PeftModel +from transformers import AutoModelForCausalLM, AutoTokenizer + +from .adapter import text_adapter_dir +from .contract import LETTERS, Question, build_messages + + +@dataclass +class Scored: + probabilities: list[float] + prompt_tokens: int + + +class TextEngine: + def __init__( + self, base_model: str, adapter_root: str, device: str, dtype: str + ) -> None: + self.device = device + self.dtype = dtype + started = time.perf_counter() + self.tokenizer = AutoTokenizer.from_pretrained(base_model) + model = AutoModelForCausalLM.from_pretrained( + base_model, dtype=getattr(torch, dtype), device_map=device + ) + model = PeftModel.from_pretrained(model, str(text_adapter_dir(adapter_root))) + model.eval() + self.model = model + self.load_seconds = time.perf_counter() - started + self.letter_ids = self._letter_ids() + + def _letter_ids(self) -> list[int]: + ids = [] + for letter in LETTERS: + tokens = self.tokenizer.encode(letter, add_special_tokens=False) + if len(tokens) != 1: + raise RuntimeError(f"letter {letter!r} is not a single token: {tokens}") + ids.append(tokens[0]) + return ids + + def encode(self, state: str, question: Question): + """Tokenized prompt for one question, on CPU. + + The Qwen3.5 tokenizer adds no special tokens here (contract point 4); + the chat template already contains them. + """ + chat_text = self.tokenizer.apply_chat_template( + build_messages(state, question), tokenize=False, add_generation_prompt=True + ) + return self.tokenizer(chat_text, return_tensors="pt") + + def prompt_ids(self, state: str, question: Question) -> list[int]: + return self.encode(state, question)["input_ids"][0].tolist() + + @torch.no_grad() + def score_encoded(self, inputs, n_options: int) -> Scored: + inputs = inputs.to(self.model.device) + out = self.model(**inputs) + final_logits = out.logits[0, -1, :] + letter_ids = self.letter_ids[:n_options] + option_logits = final_logits[ + torch.tensor(letter_ids, device=final_logits.device) + ] + probabilities = torch.softmax(option_logits.float(), dim=-1).tolist() + return Scored( + probabilities=probabilities, prompt_tokens=int(inputs["input_ids"].shape[1]) + ) + + def score(self, state: str, question: Question) -> Scored: + return self.score_encoded(self.encode(state, question), len(question.keys)) diff --git a/src/models/cua_s1/text/server.py b/src/models/cua_s1/text/server.py new file mode 100644 index 0000000..a272eec --- /dev/null +++ b/src/models/cua_s1/text/server.py @@ -0,0 +1,234 @@ +"""HTTP worker for Cua-S1 4B 0.2 (`text` adapter) behind the Rust frontend. + +Routes: `GET /health` and `POST /v1/systemone`. The model is loaded before the +server starts listening, and one forward pass runs at a time. + + PYTHONPATH=src python -m models.cua_s1.text.server --base --adapter +""" + +from __future__ import annotations + +import argparse +import asyncio +import hmac +import json +import os +import sys +import time +import traceback +from concurrent.futures import ThreadPoolExecutor +from typing import Any + +# FastAPI reads the handler annotations at runtime, so `Request` must be a +# module-level name while `from __future__ import annotations` is in effect. +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse + +from .contract import ( + ADAPTER_REVISION, + MODEL_NAME, + RequestError, + answer, + map_request, + model_identity, + parse_body, +) + +WARMUP_REQUEST = { + "model": MODEL_NAME, + "state": "Dialog: 'Update installed.' Button: OK", + "questions": { + "warmup": { + "type": "choice", + "instructions": "Close the dialog.", + "criteria": {"ok": "Click OK", "wait": "Wait"}, + } + }, +} + + +def build_app( + engine: Any, + *, + api_key: str | None, + max_body_bytes: int, + max_questions: int, + max_prompt_tokens: int, + revision: str, +): + app = FastAPI() + pool = ThreadPoolExecutor(max_workers=1) + identity = model_identity(revision) + expected_auth = ( + f"Bearer {api_key}".encode("utf-8", "surrogateescape") if api_key else b"" + ) + + def error(status: int, message: str) -> JSONResponse: + return JSONResponse({"detail": message}, status_code=status) + + def authorized(request: Request) -> bool: + if not api_key: + return True + supplied = request.headers.get("authorization", "").encode( + "utf-8", "surrogateescape" + ) + return hmac.compare_digest(supplied, expected_auth) + + @app.get("/health") + def health(): + return { + "status": "ready", + "modality": "text", + "model": identity, + "device": engine.device, + "dtype": engine.dtype, + } + + def decide(mapped): + # Tokenize every question first, so an over-long prompt is rejected + # before any forward pass runs. + encoded = [] + for question in mapped.questions: + inputs = engine.encode(mapped.state, question) + n = int(inputs["input_ids"].shape[1]) + if max_prompt_tokens and n > max_prompt_tokens: + raise RequestError( + f"question {question.name!r}: prompt is {n} tokens, " + f"over the {max_prompt_tokens}-token limit", + status=413, + ) + encoded.append((question, inputs)) + answers, prompt_tokens = {}, 0 + for question, inputs in encoded: + scored = engine.score_encoded(inputs, len(question.keys)) + answers[question.name] = answer(question, scored.probabilities) + prompt_tokens += scored.prompt_tokens + return { + "model": identity, + "answers": answers, + "usage": {"input_tokens": prompt_tokens, "output_tokens": 0}, + } + + @app.post("/v1/systemone") + async def systemone(request: Request): + if not authorized(request): + return error(401, "invalid or missing bearer token") + length = request.headers.get("content-length") + if length and length.isdigit() and int(length) > max_body_bytes: + return error(413, "request body too large") + raw = bytearray() + async for chunk in request.stream(): + raw.extend(chunk) + if len(raw) > max_body_bytes: + return error(413, "request body too large") + try: + mapped = map_request(parse_body(bytes(raw)), max_questions=max_questions) + loop = asyncio.get_running_loop() + return await loop.run_in_executor(pool, decide, mapped) + except RequestError as exc: + return error(exc.status, str(exc)) + except Exception: + traceback.print_exc(file=sys.stderr) + return error(500, "inference failed") + + def warmup() -> None: + """Run one decision on the worker thread through the full request path.""" + mapped = map_request(WARMUP_REQUEST) + json.dumps(pool.submit(decide, mapped).result(), allow_nan=False) + + app.state.warmup = warmup + return app + + +def main(argv: list[str] | None = None) -> None: + env = os.environ.get + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "--base", + default=env("CUA_S1_BASE"), + help="local Qwen/Qwen3.5-4B directory (env CUA_S1_BASE)", + ) + parser.add_argument( + "--adapter", + default=env("CUA_S1_ADAPTER"), + help="local cua-ai/cua-s1-4b-0.2 directory (env CUA_S1_ADAPTER)", + ) + parser.add_argument( + "--adapter-revision", + default=env("CUA_S1_ADAPTER_REVISION"), + help="adapter revision to report when the download metadata is missing", + ) + parser.add_argument("--device", default=env("CUA_S1_DEVICE", "cuda")) + parser.add_argument( + "--dtype", + default=env("CUA_S1_DTYPE", "bfloat16"), + choices=["bfloat16", "float16", "float32"], + ) + parser.add_argument("--host", default=env("CUA_S1_HOST", "127.0.0.1")) + parser.add_argument("--port", type=int, default=int(env("CUA_S1_PORT", "8000"))) + parser.add_argument( + "--max-body-bytes", + type=int, + default=int(env("CUA_S1_MAX_BODY_BYTES", str(4 << 20))), + ) + parser.add_argument( + "--max-questions", type=int, default=int(env("CUA_S1_MAX_QUESTIONS", "64")) + ) + parser.add_argument( + "--max-prompt-tokens", + type=int, + default=int(env("CUA_S1_MAX_PROMPT_TOKENS", "16384")), + help="per question; 0 disables the check", + ) + args = parser.parse_args(argv) + if not args.base or not args.adapter: + parser.error("--base and --adapter are required") + + import uvicorn + + from .adapter import downloaded_revision, text_adapter_dir + from .engine import TextEngine + + # Fail before loading weights if this is not the text adapter. + text_adapter_dir(args.adapter) + detected = downloaded_revision(args.adapter) + if detected and args.adapter_revision and detected != args.adapter_revision: + parser.error( + f"--adapter-revision {args.adapter_revision} does not match the " + f"downloaded revision {detected}" + ) + revision = detected or args.adapter_revision or ADAPTER_REVISION + if revision != ADAPTER_REVISION: + print( + f"warning: adapter revision {revision} is not the pinned {ADAPTER_REVISION}", + flush=True, + ) + if not detected: + print( + "note: no download metadata under --adapter; the adapter revision is not verified", + flush=True, + ) + + engine = TextEngine(args.base, args.adapter, args.device, args.dtype) + print( + f"loaded in {engine.load_seconds:.1f} s on {args.device} ({args.dtype})", + flush=True, + ) + app = build_app( + engine, + api_key=env("CUA_S1_API_KEY") or None, + max_body_bytes=args.max_body_bytes, + max_questions=args.max_questions, + max_prompt_tokens=args.max_prompt_tokens, + revision=revision, + ) + # One decision before listening, so the first real request does not pay + # for lazy weight loading or first-call kernel setup on the worker thread. + started = time.perf_counter() + app.state.warmup() + print(f"warmed up in {time.perf_counter() - started:.1f} s", flush=True) + uvicorn.run(app, host=args.host, port=args.port, log_level="warning") + + +if __name__ == "__main__": + main() diff --git a/tests/cua_s1/data/text_inputs.json b/tests/cua_s1/data/text_inputs.json new file mode 100644 index 0000000..1a56460 --- /dev/null +++ b/tests/cua_s1/data/text_inputs.json @@ -0,0 +1,289 @@ +{ + "fixture_positive": { + "model": "cua-s1-4b-0.2", + "state": "Visual-region-derived observation for capture \"capture-fixture-1\":\n\"submit-text\": text 'Submit' at (300,240,100,40) confidence=0.96 interactive=true", + "questions": { + "pick": { + "type": "choice", + "instructions": "Submit the verified form.", + "criteria": { + "submit-form": "Submit using the unique validated visual region.", + "reobserve": "Discard this decision set and obtain a fresh observation.", + "abstain": "Stop without acting if no supplied action is safe." + } + } + } + }, + "fixture_negative": { + "model": "cua-s1-4b-0.2", + "state": "Visual-region-derived observation for capture \"synthetic-negative-1\":\n\"save\": text 'Save' at (10,10,80,30) confidence=0.98 interactive=true", + "questions": { + "pick": { + "type": "choice", + "instructions": "Choose exactly one candidate by applying its condition to the supplied state. The host alone authorizes any selected action.", + "criteria": { + "region:save": "Select only if exactly one supplied region has id=save, kind=text, exact_text=\"Send\", confidence=0.98, and it is the sole exact Send match at or above 0.80.", + "reobserve": "Select only when no action candidate condition matches and no regions are supplied. Do not act; request one fresh bounded observation.", + "abstain": "Select only when no action candidate condition matches and one or more regions are supplied. Do not act; stop." + } + } + } + }, + "one_option": { + "model": "cua-s1-4b-0.2", + "state": "Dialog: 'Update installed.' Button: OK", + "questions": { + "pick": { + "type": "choice", + "instructions": "Close the dialog.", + "criteria": { + "ok": "Click OK" + } + } + } + }, + "two_options": { + "model": "cua-s1-4b-0.2", + "state": "Dialog: 'Delete 3 files permanently?' Buttons: Delete, Cancel", + "questions": { + "pick": { + "type": "choice", + "instructions": "Keep the files.", + "criteria": { + "delete": "Click Delete", + "cancel": "Click Cancel" + } + } + } + }, + "max_26_options": { + "model": "cua-s1-4b-0.2", + "state": "Toolbar of a document editor. Selected text: 'quarterly results'.\nbutton 'Undo' enabled=true\nbutton 'Redo' enabled=true\nbutton 'Cut' enabled=true\nbutton 'Copy' enabled=true\nbutton 'Paste' enabled=true\nbutton 'Bold' enabled=true\nbutton 'Italic' enabled=true\nbutton 'Underline' enabled=true\nbutton 'Strikethrough' enabled=true\nbutton 'Font color' enabled=true\nbutton 'Highlight' enabled=true\nbutton 'Align left' enabled=true\nbutton 'Center' enabled=true\nbutton 'Align right' enabled=true\nbutton 'Justify' enabled=true\nbutton 'Bullets' enabled=true\nbutton 'Numbering' enabled=true\nbutton 'Indent' enabled=true\nbutton 'Outdent' enabled=true\nbutton 'Insert link' enabled=true\nbutton 'Insert image' enabled=true\nbutton 'Insert table' enabled=true\nbutton 'Comment' enabled=true\nbutton 'Find' enabled=true\nbutton 'Replace' enabled=true\nbutton 'Print' enabled=true", + "questions": { + "pick": { + "type": "choice", + "instructions": "Make the selected text bold.", + "criteria": { + "undo": "Click the 'Undo' button", + "redo": "Click the 'Redo' button", + "cut": "Click the 'Cut' button", + "copy": "Click the 'Copy' button", + "paste": "Click the 'Paste' button", + "bold": "Click the 'Bold' button", + "italic": "Click the 'Italic' button", + "underline": "Click the 'Underline' button", + "strikethrough": "Click the 'Strikethrough' button", + "font-color": "Click the 'Font color' button", + "highlight": "Click the 'Highlight' button", + "align-left": "Click the 'Align left' button", + "center": "Click the 'Center' button", + "align-right": "Click the 'Align right' button", + "justify": "Click the 'Justify' button", + "bullets": "Click the 'Bullets' button", + "numbering": "Click the 'Numbering' button", + "indent": "Click the 'Indent' button", + "outdent": "Click the 'Outdent' button", + "insert-link": "Click the 'Insert link' button", + "insert-image": "Click the 'Insert image' button", + "insert-table": "Click the 'Insert table' button", + "comment": "Click the 'Comment' button", + "find": "Click the 'Find' button", + "replace": "Click the 'Replace' button", + "print": "Click the 'Print' button" + } + } + } + }, + "long_state": { + "model": "cua-s1-4b-0.2", + "state": "Orders table (web admin), 300 rows, sorted by order number.\nrow 0: cell 'Order #10000' | cell 'Customer 0' | cell '10.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 1: cell 'Order #10001' | cell 'Customer 1' | cell '17.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 2: cell 'Order #10002' | cell 'Customer 2' | cell '24.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 3: cell 'Order #10003' | cell 'Customer 3' | cell '31.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 4: cell 'Order #10004' | cell 'Customer 4' | cell '38.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 5: cell 'Order #10005' | cell 'Customer 5' | cell '45.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 6: cell 'Order #10006' | cell 'Customer 6' | cell '52.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 7: cell 'Order #10007' | cell 'Customer 7' | cell '59.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 8: cell 'Order #10008' | cell 'Customer 8' | cell '66.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 9: cell 'Order #10009' | cell 'Customer 9' | cell '73.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 10: cell 'Order #10010' | cell 'Customer 10' | cell '80.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 11: cell 'Order #10011' | cell 'Customer 11' | cell '87.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 12: cell 'Order #10012' | cell 'Customer 12' | cell '94.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 13: cell 'Order #10013' | cell 'Customer 13' | cell '11.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 14: cell 'Order #10014' | cell 'Customer 14' | cell '18.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 15: cell 'Order #10015' | cell 'Customer 15' | cell '25.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 16: cell 'Order #10016' | cell 'Customer 16' | cell '32.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 17: cell 'Order #10017' | cell 'Customer 17' | cell '39.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 18: cell 'Order #10018' | cell 'Customer 18' | cell '46.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 19: cell 'Order #10019' | cell 'Customer 19' | cell '53.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 20: cell 'Order #10020' | cell 'Customer 20' | cell '60.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 21: cell 'Order #10021' | cell 'Customer 21' | cell '67.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 22: cell 'Order #10022' | cell 'Customer 22' | cell '74.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 23: cell 'Order #10023' | cell 'Customer 23' | cell '81.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 24: cell 'Order #10024' | cell 'Customer 24' | cell '88.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 25: cell 'Order #10025' | cell 'Customer 25' | cell '95.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 26: cell 'Order #10026' | cell 'Customer 26' | cell '12.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 27: cell 'Order #10027' | cell 'Customer 27' | cell '19.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 28: cell 'Order #10028' | cell 'Customer 28' | cell '26.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 29: cell 'Order #10029' | cell 'Customer 29' | cell '33.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 30: cell 'Order #10030' | cell 'Customer 30' | cell '40.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 31: cell 'Order #10031' | cell 'Customer 31' | cell '47.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 32: cell 'Order #10032' | cell 'Customer 32' | cell '54.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 33: cell 'Order #10033' | cell 'Customer 33' | cell '61.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 34: cell 'Order #10034' | cell 'Customer 34' | cell '68.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 35: cell 'Order #10035' | cell 'Customer 35' | cell '75.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 36: cell 'Order #10036' | cell 'Customer 36' | cell '82.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 37: cell 'Order #10037' | cell 'Customer 0' | cell '89.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 38: cell 'Order #10038' | cell 'Customer 1' | cell '96.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 39: cell 'Order #10039' | cell 'Customer 2' | cell '13.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 40: cell 'Order #10040' | cell 'Customer 3' | cell '20.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 41: cell 'Order #10041' | cell 'Customer 4' | cell '27.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 42: cell 'Order #10042' | cell 'Customer 5' | cell '34.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 43: cell 'Order #10043' | cell 'Customer 6' | cell '41.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 44: cell 'Order #10044' | cell 'Customer 7' | cell '48.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 45: cell 'Order #10045' | cell 'Customer 8' | cell '55.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 46: cell 'Order #10046' | cell 'Customer 9' | cell '62.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 47: cell 'Order #10047' | cell 'Customer 10' | cell '69.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 48: cell 'Order #10048' | cell 'Customer 11' | cell '76.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 49: cell 'Order #10049' | cell 'Customer 12' | cell '83.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 50: cell 'Order #10050' | cell 'Customer 13' | cell '90.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 51: cell 'Order #10051' | cell 'Customer 14' | cell '97.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 52: cell 'Order #10052' | cell 'Customer 15' | cell '14.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 53: cell 'Order #10053' | cell 'Customer 16' | cell '21.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 54: cell 'Order #10054' | cell 'Customer 17' | cell '28.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 55: cell 'Order #10055' | cell 'Customer 18' | cell '35.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 56: cell 'Order #10056' | cell 'Customer 19' | cell '42.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 57: cell 'Order #10057' | cell 'Customer 20' | cell '49.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 58: cell 'Order #10058' | cell 'Customer 21' | cell '56.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 59: cell 'Order #10059' | cell 'Customer 22' | cell '63.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 60: cell 'Order #10060' | cell 'Customer 23' | cell '70.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 61: cell 'Order #10061' | cell 'Customer 24' | cell '77.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 62: cell 'Order #10062' | cell 'Customer 25' | cell '84.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 63: cell 'Order #10063' | cell 'Customer 26' | cell '91.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 64: cell 'Order #10064' | cell 'Customer 27' | cell '98.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 65: cell 'Order #10065' | cell 'Customer 28' | cell '15.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 66: cell 'Order #10066' | cell 'Customer 29' | cell '22.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 67: cell 'Order #10067' | cell 'Customer 30' | cell '29.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 68: cell 'Order #10068' | cell 'Customer 31' | cell '36.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 69: cell 'Order #10069' | cell 'Customer 32' | cell '43.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 70: cell 'Order #10070' | cell 'Customer 33' | cell '50.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 71: cell 'Order #10071' | cell 'Customer 34' | cell '57.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 72: cell 'Order #10072' | cell 'Customer 35' | cell '64.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 73: cell 'Order #10073' | cell 'Customer 36' | cell '71.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 74: cell 'Order #10074' | cell 'Customer 0' | cell '78.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 75: cell 'Order #10075' | cell 'Customer 1' | cell '85.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 76: cell 'Order #10076' | cell 'Customer 2' | cell '92.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 77: cell 'Order #10077' | cell 'Customer 3' | cell '99.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 78: cell 'Order #10078' | cell 'Customer 4' | cell '16.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 79: cell 'Order #10079' | cell 'Customer 5' | cell '23.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 80: cell 'Order #10080' | cell 'Customer 6' | cell '30.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 81: cell 'Order #10081' | cell 'Customer 7' | cell '37.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 82: cell 'Order #10082' | cell 'Customer 8' | cell '44.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 83: cell 'Order #10083' | cell 'Customer 9' | cell '51.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 84: cell 'Order #10084' | cell 'Customer 10' | cell '58.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 85: cell 'Order #10085' | cell 'Customer 11' | cell '65.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 86: cell 'Order #10086' | cell 'Customer 12' | cell '72.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 87: cell 'Order #10087' | cell 'Customer 13' | cell '79.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 88: cell 'Order #10088' | cell 'Customer 14' | cell '86.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 89: cell 'Order #10089' | cell 'Customer 15' | cell '93.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 90: cell 'Order #10090' | cell 'Customer 16' | cell '10.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 91: cell 'Order #10091' | cell 'Customer 17' | cell '17.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 92: cell 'Order #10092' | cell 'Customer 18' | cell '24.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 93: cell 'Order #10093' | cell 'Customer 19' | cell '31.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 94: cell 'Order #10094' | cell 'Customer 20' | cell '38.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 95: cell 'Order #10095' | cell 'Customer 21' | cell '45.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 96: cell 'Order #10096' | cell 'Customer 22' | cell '52.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 97: cell 'Order #10097' | cell 'Customer 23' | cell '59.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 98: cell 'Order #10098' | cell 'Customer 24' | cell '66.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 99: cell 'Order #10099' | cell 'Customer 25' | cell '73.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 100: cell 'Order #10100' | cell 'Customer 26' | cell '80.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 101: cell 'Order #10101' | cell 'Customer 27' | cell '87.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 102: cell 'Order #10102' | cell 'Customer 28' | cell '94.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 103: cell 'Order #10103' | cell 'Customer 29' | cell '11.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 104: cell 'Order #10104' | cell 'Customer 30' | cell '18.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 105: cell 'Order #10105' | cell 'Customer 31' | cell '25.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 106: cell 'Order #10106' | cell 'Customer 32' | cell '32.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 107: cell 'Order #10107' | cell 'Customer 33' | cell '39.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 108: cell 'Order #10108' | cell 'Customer 34' | cell '46.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 109: cell 'Order #10109' | cell 'Customer 35' | cell '53.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 110: cell 'Order #10110' | cell 'Customer 36' | cell '60.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 111: cell 'Order #10111' | cell 'Customer 0' | cell '67.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 112: cell 'Order #10112' | cell 'Customer 1' | cell '74.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 113: cell 'Order #10113' | cell 'Customer 2' | cell '81.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 114: cell 'Order #10114' | cell 'Customer 3' | cell '88.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 115: cell 'Order #10115' | cell 'Customer 4' | cell '95.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 116: cell 'Order #10116' | cell 'Customer 5' | cell '12.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 117: cell 'Order #10117' | cell 'Customer 6' | cell '19.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 118: cell 'Order #10118' | cell 'Customer 7' | cell '26.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 119: cell 'Order #10119' | cell 'Customer 8' | cell '33.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 120: cell 'Order #10120' | cell 'Customer 9' | cell '40.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 121: cell 'Order #10121' | cell 'Customer 10' | cell '47.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 122: cell 'Order #10122' | cell 'Customer 11' | cell '54.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 123: cell 'Order #10123' | cell 'Customer 12' | cell '61.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 124: cell 'Order #10124' | cell 'Customer 13' | cell '68.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 125: cell 'Order #10125' | cell 'Customer 14' | cell '75.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 126: cell 'Order #10126' | cell 'Customer 15' | cell '82.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 127: cell 'Order #10127' | cell 'Customer 16' | cell '89.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 128: cell 'Order #10128' | cell 'Customer 17' | cell '96.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 129: cell 'Order #10129' | cell 'Customer 18' | cell '13.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 130: cell 'Order #10130' | cell 'Customer 19' | cell '20.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 131: cell 'Order #10131' | cell 'Customer 20' | cell '27.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 132: cell 'Order #10132' | cell 'Customer 21' | cell '34.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 133: cell 'Order #10133' | cell 'Customer 22' | cell '41.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 134: cell 'Order #10134' | cell 'Customer 23' | cell '48.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 135: cell 'Order #10135' | cell 'Customer 24' | cell '55.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 136: cell 'Order #10136' | cell 'Customer 25' | cell '62.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 137: cell 'Order #10137' | cell 'Customer 26' | cell '69.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 138: cell 'Order #10138' | cell 'Customer 27' | cell '76.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 139: cell 'Order #10139' | cell 'Customer 28' | cell '83.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 140: cell 'Order #10140' | cell 'Customer 29' | cell '90.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 141: cell 'Order #10141' | cell 'Customer 30' | cell '97.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 142: cell 'Order #10142' | cell 'Customer 31' | cell '14.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 143: cell 'Order #10143' | cell 'Customer 32' | cell '21.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 144: cell 'Order #10144' | cell 'Customer 33' | cell '28.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 145: cell 'Order #10145' | cell 'Customer 34' | cell '35.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 146: cell 'Order #10146' | cell 'Customer 35' | cell '42.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 147: cell 'Order #10147' | cell 'Customer 36' | cell '49.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 148: cell 'Order #10148' | cell 'Customer 0' | cell '56.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 149: cell 'Order #10149' | cell 'Customer 1' | cell '63.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 150: cell 'Order #10150' | cell 'Customer 2' | cell '70.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 151: cell 'Order #10151' | cell 'Customer 3' | cell '77.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 152: cell 'Order #10152' | cell 'Customer 4' | cell '84.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 153: cell 'Order #10153' | cell 'Customer 5' | cell '91.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 154: cell 'Order #10154' | cell 'Customer 6' | cell '98.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 155: cell 'Order #10155' | cell 'Customer 7' | cell '15.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 156: cell 'Order #10156' | cell 'Customer 8' | cell '22.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 157: cell 'Order #10157' | cell 'Customer 9' | cell '29.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 158: cell 'Order #10158' | cell 'Customer 10' | cell '36.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 159: cell 'Order #10159' | cell 'Customer 11' | cell '43.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 160: cell 'Order #10160' | cell 'Customer 12' | cell '50.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 161: cell 'Order #10161' | cell 'Customer 13' | cell '57.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 162: cell 'Order #10162' | cell 'Customer 14' | cell '64.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 163: cell 'Order #10163' | cell 'Customer 15' | cell '71.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 164: cell 'Order #10164' | cell 'Customer 16' | cell '78.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 165: cell 'Order #10165' | cell 'Customer 17' | cell '85.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 166: cell 'Order #10166' | cell 'Customer 18' | cell '92.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 167: cell 'Order #10167' | cell 'Customer 19' | cell '99.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 168: cell 'Order #10168' | cell 'Customer 20' | cell '16.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 169: cell 'Order #10169' | cell 'Customer 21' | cell '23.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 170: cell 'Order #10170' | cell 'Customer 22' | cell '30.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 171: cell 'Order #10171' | cell 'Customer 23' | cell '37.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 172: cell 'Order #10172' | cell 'Customer 24' | cell '44.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 173: cell 'Order #10173' | cell 'Customer 25' | cell '51.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 174: cell 'Order #10174' | cell 'Customer 26' | cell '58.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 175: cell 'Order #10175' | cell 'Customer 27' | cell '65.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 176: cell 'Order #10176' | cell 'Customer 28' | cell '72.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 177: cell 'Order #10177' | cell 'Customer 29' | cell '79.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 178: cell 'Order #10178' | cell 'Customer 30' | cell '86.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 179: cell 'Order #10179' | cell 'Customer 31' | cell '93.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 180: cell 'Order #10180' | cell 'Customer 32' | cell '10.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 181: cell 'Order #10181' | cell 'Customer 33' | cell '17.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 182: cell 'Order #10182' | cell 'Customer 34' | cell '24.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 183: cell 'Order #10183' | cell 'Customer 35' | cell '31.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 184: cell 'Order #10184' | cell 'Customer 36' | cell '38.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 185: cell 'Order #10185' | cell 'Customer 0' | cell '45.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 186: cell 'Order #10186' | cell 'Customer 1' | cell '52.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 187: cell 'Order #10187' | cell 'Customer 2' | cell '59.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 188: cell 'Order #10188' | cell 'Customer 3' | cell '66.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 189: cell 'Order #10189' | cell 'Customer 4' | cell '73.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 190: cell 'Order #10190' | cell 'Customer 5' | cell '80.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 191: cell 'Order #10191' | cell 'Customer 6' | cell '87.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 192: cell 'Order #10192' | cell 'Customer 7' | cell '94.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 193: cell 'Order #10193' | cell 'Customer 8' | cell '11.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 194: cell 'Order #10194' | cell 'Customer 9' | cell '18.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 195: cell 'Order #10195' | cell 'Customer 10' | cell '25.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 196: cell 'Order #10196' | cell 'Customer 11' | cell '32.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 197: cell 'Order #10197' | cell 'Customer 12' | cell '39.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 198: cell 'Order #10198' | cell 'Customer 13' | cell '46.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 199: cell 'Order #10199' | cell 'Customer 14' | cell '53.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 200: cell 'Order #10200' | cell 'Customer 15' | cell '60.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 201: cell 'Order #10201' | cell 'Customer 16' | cell '67.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 202: cell 'Order #10202' | cell 'Customer 17' | cell '74.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 203: cell 'Order #10203' | cell 'Customer 18' | cell '81.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 204: cell 'Order #10204' | cell 'Customer 19' | cell '88.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 205: cell 'Order #10205' | cell 'Customer 20' | cell '95.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 206: cell 'Order #10206' | cell 'Customer 21' | cell '12.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 207: cell 'Order #10207' | cell 'Customer 22' | cell '19.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 208: cell 'Order #10208' | cell 'Customer 23' | cell '26.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 209: cell 'Order #10209' | cell 'Customer 24' | cell '33.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 210: cell 'Order #10210' | cell 'Customer 25' | cell '40.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 211: cell 'Order #10211' | cell 'Customer 26' | cell '47.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 212: cell 'Order #10212' | cell 'Customer 27' | cell '54.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 213: cell 'Order #10213' | cell 'Customer 28' | cell '61.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 214: cell 'Order #10214' | cell 'Customer 29' | cell '68.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 215: cell 'Order #10215' | cell 'Customer 30' | cell '75.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 216: cell 'Order #10216' | cell 'Customer 31' | cell '82.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 217 note: customer reported a duplicate charge on this order\nrow 217: cell 'Order #10217' | cell 'Customer 32' | cell '89.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 218: cell 'Order #10218' | cell 'Customer 33' | cell '96.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 219: cell 'Order #10219' | cell 'Customer 34' | cell '13.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 220: cell 'Order #10220' | cell 'Customer 35' | cell '20.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 221: cell 'Order #10221' | cell 'Customer 36' | cell '27.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 222: cell 'Order #10222' | cell 'Customer 0' | cell '34.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 223: cell 'Order #10223' | cell 'Customer 1' | cell '41.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 224: cell 'Order #10224' | cell 'Customer 2' | cell '48.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 225: cell 'Order #10225' | cell 'Customer 3' | cell '55.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 226: cell 'Order #10226' | cell 'Customer 4' | cell '62.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 227: cell 'Order #10227' | cell 'Customer 5' | cell '69.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 228: cell 'Order #10228' | cell 'Customer 6' | cell '76.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 229: cell 'Order #10229' | cell 'Customer 7' | cell '83.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 230: cell 'Order #10230' | cell 'Customer 8' | cell '90.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 231: cell 'Order #10231' | cell 'Customer 9' | cell '97.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 232: cell 'Order #10232' | cell 'Customer 10' | cell '14.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 233: cell 'Order #10233' | cell 'Customer 11' | cell '21.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 234: cell 'Order #10234' | cell 'Customer 12' | cell '28.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 235: cell 'Order #10235' | cell 'Customer 13' | cell '35.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 236: cell 'Order #10236' | cell 'Customer 14' | cell '42.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 237: cell 'Order #10237' | cell 'Customer 15' | cell '49.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 238: cell 'Order #10238' | cell 'Customer 16' | cell '56.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 239: cell 'Order #10239' | cell 'Customer 17' | cell '63.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 240: cell 'Order #10240' | cell 'Customer 18' | cell '70.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 241: cell 'Order #10241' | cell 'Customer 19' | cell '77.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 242: cell 'Order #10242' | cell 'Customer 20' | cell '84.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 243: cell 'Order #10243' | cell 'Customer 21' | cell '91.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 244: cell 'Order #10244' | cell 'Customer 22' | cell '98.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 245: cell 'Order #10245' | cell 'Customer 23' | cell '15.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 246: cell 'Order #10246' | cell 'Customer 24' | cell '22.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 247: cell 'Order #10247' | cell 'Customer 25' | cell '29.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 248: cell 'Order #10248' | cell 'Customer 26' | cell '36.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 249: cell 'Order #10249' | cell 'Customer 27' | cell '43.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 250: cell 'Order #10250' | cell 'Customer 28' | cell '50.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 251: cell 'Order #10251' | cell 'Customer 29' | cell '57.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 252: cell 'Order #10252' | cell 'Customer 30' | cell '64.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 253: cell 'Order #10253' | cell 'Customer 31' | cell '71.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 254: cell 'Order #10254' | cell 'Customer 32' | cell '78.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 255: cell 'Order #10255' | cell 'Customer 33' | cell '85.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 256: cell 'Order #10256' | cell 'Customer 34' | cell '92.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 257: cell 'Order #10257' | cell 'Customer 35' | cell '99.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 258: cell 'Order #10258' | cell 'Customer 36' | cell '16.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 259: cell 'Order #10259' | cell 'Customer 0' | cell '23.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 260: cell 'Order #10260' | cell 'Customer 1' | cell '30.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 261: cell 'Order #10261' | cell 'Customer 2' | cell '37.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 262: cell 'Order #10262' | cell 'Customer 3' | cell '44.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 263: cell 'Order #10263' | cell 'Customer 4' | cell '51.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 264: cell 'Order #10264' | cell 'Customer 5' | cell '58.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 265: cell 'Order #10265' | cell 'Customer 6' | cell '65.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 266: cell 'Order #10266' | cell 'Customer 7' | cell '72.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 267: cell 'Order #10267' | cell 'Customer 8' | cell '79.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 268: cell 'Order #10268' | cell 'Customer 9' | cell '86.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 269: cell 'Order #10269' | cell 'Customer 10' | cell '93.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 270: cell 'Order #10270' | cell 'Customer 11' | cell '10.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 271: cell 'Order #10271' | cell 'Customer 12' | cell '17.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 272: cell 'Order #10272' | cell 'Customer 13' | cell '24.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 273: cell 'Order #10273' | cell 'Customer 14' | cell '31.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 274: cell 'Order #10274' | cell 'Customer 15' | cell '38.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 275: cell 'Order #10275' | cell 'Customer 16' | cell '45.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 276: cell 'Order #10276' | cell 'Customer 17' | cell '52.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 277: cell 'Order #10277' | cell 'Customer 18' | cell '59.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 278: cell 'Order #10278' | cell 'Customer 19' | cell '66.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 279: cell 'Order #10279' | cell 'Customer 20' | cell '73.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 280: cell 'Order #10280' | cell 'Customer 21' | cell '80.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 281: cell 'Order #10281' | cell 'Customer 22' | cell '87.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 282: cell 'Order #10282' | cell 'Customer 23' | cell '94.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 283: cell 'Order #10283' | cell 'Customer 24' | cell '11.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 284: cell 'Order #10284' | cell 'Customer 25' | cell '18.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 285: cell 'Order #10285' | cell 'Customer 26' | cell '25.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 286: cell 'Order #10286' | cell 'Customer 27' | cell '32.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 287: cell 'Order #10287' | cell 'Customer 28' | cell '39.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 288: cell 'Order #10288' | cell 'Customer 29' | cell '46.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 289: cell 'Order #10289' | cell 'Customer 30' | cell '53.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 290: cell 'Order #10290' | cell 'Customer 31' | cell '60.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 291: cell 'Order #10291' | cell 'Customer 32' | cell '67.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 292: cell 'Order #10292' | cell 'Customer 33' | cell '74.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 293: cell 'Order #10293' | cell 'Customer 34' | cell '81.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 294: cell 'Order #10294' | cell 'Customer 35' | cell '88.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 295: cell 'Order #10295' | cell 'Customer 36' | cell '95.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 296: cell 'Order #10296' | cell 'Customer 0' | cell '12.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 297: cell 'Order #10297' | cell 'Customer 1' | cell '19.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 298: cell 'Order #10298' | cell 'Customer 2' | cell '26.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 299: cell 'Order #10299' | cell 'Customer 3' | cell '33.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'", + "questions": { + "pick": { + "type": "choice", + "instructions": "Refund the order with the reported duplicate charge.", + "criteria": { + "refund-217": "Click 'Refund' in row 217", + "refund-216": "Click 'Refund' in row 216", + "open-217": "Click 'Open' in row 217", + "scroll": "Scroll down to see more rows", + "abstain": "Stop without acting" + } + } + } + }, + "structured": { + "model": "cua-s1-4b-0.2", + "state": { + "app": "Settings", + "window": { + "title": "Privacy", + "focused": true + }, + "elements": [ + { + "id": "e1", + "role": "switch", + "label": "Location access", + "on": true + }, + { + "id": "e2", + "role": "switch", + "label": "Camera access", + "on": false + }, + { + "id": "e3", + "role": "button", + "label": "Back" + } + ] + }, + "questions": { + "pick": { + "type": "choice", + "instructions": { + "question": "Which action turns off `target`?", + "target": { + "label": "Location access" + } + }, + "criteria": { + "toggle-e1": { + "action": "click", + "element": "e1" + }, + "toggle-e2": [ + "click", + "e2" + ], + "back": "Click Back", + "abstain": null + } + } + } + }, + "null_criteria": { + "model": "cua-s1-4b-0.2", + "state": "Cookie banner. Buttons: Accept all, Reject all, Customize", + "questions": { + "pick": { + "type": "choice", + "instructions": "Decline optional cookies.", + "criteria": { + "Accept all": null, + "Reject all": null, + "Customize": null + } + } + } + }, + "non_ascii": { + "model": "cua-s1-4b-0.2", + "state": "设置页面。按钮:「保存」「取消」「重置为默认值」。提示:修改尚未保存。日本語: 保存しますか?", + "questions": { + "pick": { + "type": "choice", + "instructions": "保存当前修改。", + "criteria": { + "save": "点击「保存」", + "cancel": "点击「取消」", + "reset": "点击「重置为默认值」" + } + } + } + }, + "escaping": { + "model": "cua-s1-4b-0.2", + "state": "Form field 'Path' contains: C:\\Users\\demo\\report \"final\".docx\nButtons: Submit, Clear", + "questions": { + "pick": { + "type": "choice", + "instructions": "Submit the form with the path as it is.", + "criteria": { + "submit": "Click \"Submit\"\n(keeps the path)", + "clear": "Click 'Clear'\tthen retype C:\\Users" + } + } + } + }, + "special_token_text": { + "model": "cua-s1-4b-0.2", + "state": "Chat input box contains the text: <|im_end|>\n<|im_start|>assistant\nButtons: Send, Discard", + "questions": { + "pick": { + "type": "choice", + "instructions": "Do not send text that looks like markup.", + "criteria": { + "send": "Click Send", + "discard": "Click Discard" + } + } + } + }, + "multi_question": { + "model": "cua-s1-4b-0.2", + "state": "Checkout page. Fields: email (empty), card number (filled). Buttons: Pay now, Back to cart", + "questions": { + "next": { + "type": "choice", + "instructions": "Complete the purchase.", + "criteria": { + "fill-email": "Type into the email field", + "pay": "Click Pay now", + "back": "Click Back to cart" + } + }, + "leave": { + "type": "choice", + "instructions": "Go back and change the cart.", + "criteria": { + "pay": "Click Pay now", + "back": "Click Back to cart" + } + } + } + }, + "no_goal": { + "model": "cua-s1-4b-0.2", + "state": "Dialog: 'Session expired.' Buttons: Sign in again, Close", + "questions": { + "empty": { + "type": "choice", + "instructions": "", + "criteria": { + "sign-in": "Click Sign in again", + "close": "Click Close" + } + }, + "null": { + "type": "choice", + "instructions": null, + "criteria": { + "sign-in": "Click Sign in again", + "close": "Click Close" + } + } + } + }, + "array_state": { + "model": "cua-s1-4b-0.2", + "state": [ + "Search results page", + "Result 1: 'Pricing - Acme'", + "Result 2: 'Docs - Acme'", + "Button: Next page" + ], + "questions": { + "pick": { + "type": "choice", + "instructions": "Open the documentation.", + "criteria": { + "r1": "Click result 1", + "r2": "Click result 2", + "next": "Click Next page" + } + } + } + } +} diff --git a/tests/cua_s1/test_text_adapter.py b/tests/cua_s1/test_text_adapter.py new file mode 100644 index 0000000..e325544 --- /dev/null +++ b/tests/cua_s1/test_text_adapter.py @@ -0,0 +1,36 @@ +"""Adapter directory checks: no weights, no torch.""" + +import json + +import pytest + +from models.cua_s1.text.adapter import downloaded_revision, text_adapter_dir + +REV = "16818868b0cc7813808aae4e87b417657046ab79" + + +def write_adapter(path, targets): + path.mkdir(parents=True) + config = {"base_model_name_or_path": "Qwen/Qwen3.5-4B", "target_modules": targets} + (path / "adapter_config.json").write_text(json.dumps(config)) + + +def test_text_adapter_dir(tmp_path): + write_adapter(tmp_path / "text", ["q_proj", "down_proj"]) + write_adapter(tmp_path / "multimodal", ["q_proj", "linear_fc1"]) + assert text_adapter_dir(tmp_path) == tmp_path / "text" + assert text_adapter_dir(tmp_path / "text") == tmp_path / "text" + with pytest.raises(RuntimeError, match="multimodal adapter"): + text_adapter_dir(tmp_path / "multimodal") + + +def test_downloaded_revision_from_repository_root(tmp_path): + write_adapter(tmp_path / "text", ["q_proj"]) + meta = ( + tmp_path / ".cache/huggingface/download/text/adapter_model.safetensors.metadata" + ) + meta.parent.mkdir(parents=True) + meta.write_text(f"{REV}\nabc\n1\n") + assert downloaded_revision(tmp_path) == REV + assert downloaded_revision(tmp_path / "text") == REV + assert downloaded_revision(tmp_path / "missing") is None diff --git a/tests/cua_s1/test_text_contract.py b/tests/cua_s1/test_text_contract.py new file mode 100644 index 0000000..1d6cd2b --- /dev/null +++ b/tests/cua_s1/test_text_contract.py @@ -0,0 +1,216 @@ +"""Contract tests that need neither weights nor torch. + +PYTHONPATH=src python -m pytest tests/cua_s1 +""" + +import json +import math +from pathlib import Path + +import pytest + +from models.cua_s1.text.contract import ( + RequestError, + answer, + build_messages, + confidence, + map_request, + parse_body, +) + +INPUTS = json.loads( + (Path(__file__).parent / "data" / "text_inputs.json").read_text(encoding="utf-8") +) + +# The user message upstream's chooser builds for +# libs/cua-driver/examples/jev-use/fixtures/jev-choice-request-v1.json at the +# pinned revision (FourBModel text modality). +FIXTURE_POSITIVE_USER = ( + "Goal: Submit the verified form.\n\n" + "App: Cua Driver\nTask family: closed-candidate decision\n\n" + "Accessibility tree:\n" + 'Visual-region-derived observation for capture "capture-fixture-1":\n' + "\"submit-text\": text 'Submit' at (300,240,100,40) confidence=0.96 interactive=true\n\n" + "Options:\n" + 'A. Decision "Submit using the unique validated visual region." -> select\n' + 'B. Decision "Discard this decision set and obtain a fresh observation." -> select\n' + 'C. Decision "Stop without acting if no supplied action is safe." -> select\n\n' + "Answer with a single letter." +) + + +def mapped(name): + return map_request(parse_body(json.dumps(INPUTS[name]).encode())) + + +def reject(body, status=422): + raw = body if isinstance(body, bytes) else json.dumps(body).encode() + with pytest.raises(RequestError) as info: + map_request(parse_body(raw)) + assert info.value.status == status + return str(info.value) + + +def base(**question): + q = { + "type": "choice", + "instructions": "Pick one.", + "criteria": {"a": "A", "b": "B"}, + } + q.update(question) + return {"model": "cua-s1-4b-0.2", "state": "Screen", "questions": {"q": q}} + + +def test_fixture_prompt_matches_upstream(): + request = mapped("fixture_positive") + messages = build_messages(request.state, request.questions[0]) + assert messages[1] == {"role": "user", "content": FIXTURE_POSITIVE_USER} + assert messages[0]["role"] == "system" + assert messages[0]["content"].startswith( + "You are a one-pass computer-use decision model." + ) + + +def test_every_input_maps(): + for name in INPUTS: + request = mapped(name) + assert request.questions + for question in request.questions: + assert 1 <= len(question.keys) <= 26 + + +def test_goal_line_left_out_when_empty_or_null(): + request = mapped("no_goal") + for question in request.questions: + user = build_messages(request.state, question)[1]["content"] + assert user.startswith("App: Cua Driver\n") + + +def test_structured_values_and_null_label(): + request = mapped("structured") + state = INPUTS["structured"]["state"] + assert request.state == json.dumps(state, ensure_ascii=False) + question = request.questions[0] + assert question.goal.startswith('{"question": "Which action turns off `target`?"') + assert question.labels[0] == '{\\"action\\": \\"click\\", \\"element\\": \\"e1\\"}' + assert question.labels[1] == '[\\"click\\", \\"e2\\"]' + assert question.labels[3] == "abstain" + + +def test_label_escaping_matches_chooser(): + question = mapped("escaping").questions[0] + assert question.labels[0] == 'Click \\"Submit\\"\\n(keeps the path)' + assert question.labels[1] == "Click 'Clear'\\tthen retype C:\\\\Users" + assert mapped("non_ascii").questions[0].labels[0] == "点击「保存」" + + +def test_score_or_noul_rejects_the_whole_request(): + body = base() + body["questions"]["s"] = { + "type": "score", + "instructions": "Rate it.", + "criteria": ["low", "high"], + } + assert "'score' is not supported" in reject(body) + body = base() + body["questions"]["n"] = {"type": "noul", "instructions": "Is it red?"} + assert "'noul' is not supported" in reject(body) + + +def test_option_count_limits(): + assert "at least one option" in reject(base(criteria={})) + many = {f"o{i}": f"Option {i}" for i in range(27)} + assert "27 options" in reject(base(criteria=many)) + assert ( + len( + map_request(base(criteria={f"o{i}": "x" for i in range(26)})) + .questions[0] + .keys + ) + == 26 + ) + + +def test_duplicate_keys_anywhere(): + raw = ( + b'{"model": "cua-s1-4b-0.2", "state": "S", "questions": {"q": {"type": "choice",' + b' "instructions": "I", "criteria": {"a": "A", "a": "B"}}}}' + ) + assert "duplicate key 'a'" in reject(raw, status=400) + raw = ( + b'{"model": "cua-s1-4b-0.2", "state": {"x": 1, "x": 2}, "questions": {"q": {"type":' + b' "choice", "instructions": "I", "criteria": {"a": "A"}}}}' + ) + assert "duplicate key 'x'" in reject(raw, status=400) + + +@pytest.mark.parametrize( + "raw", + [ + b'{"model": "cua-s1-4b-0.2", "state": NaN}', + b'{"model": "cua-s1-4b-0.2", "state": {"x": 1e400}}', + b'{"model": "cua-s1-4b-0.2", "state": {"x": ' + b"9" * 5000 + b"}}", + b'{"model": "cua-s1-4b-0.2", "state": "\\ud800"}', + b"[" * 100000 + b"]" * 100000, + b"\xff\xfe", + '{"model": "cua-s1-4b-0.2", "state": "S"}'.encode("utf-16"), + b"\xef\xbb\xbf" + b'{"model": "cua-s1-4b-0.2", "state": "S"}', + ], +) +def test_malformed_bodies_are_400(raw): + reject(raw, status=400) + + +def test_question_shape_errors(): + body = base() + body["questions"]["q"] = "not an object" + assert "must be an object" in reject(body) + assert "'criteria' must be an object" in reject(base(criteria=["a", "b"])) + body = base() + del body["questions"]["q"]["instructions"] + assert "'instructions' is required" in reject(body) + assert "unknown type 'rank'" in reject(base(type="rank")) + body = base() + body["questions"] = {f"q{i}": body["questions"]["q"] for i in range(3)} + with pytest.raises(RequestError) as info: + map_request(body, max_questions=2) + assert info.value.status == 413 + + +@pytest.mark.parametrize("value", [1, 2.5, True, False]) +def test_number_or_boolean_criteria_value(value): + assert "must be a string, an object or an array" in reject( + base(criteria={"a": value, "b": "B"}) + ) + + +@pytest.mark.parametrize("state", ["", {}, [], None, 3, True]) +def test_bad_state(state): + body = base() + body["state"] = state + reject(body) + + +def test_model_name_and_body_shape(): + body = base() + body["model"] = "english" + assert "'model' must be" in reject(body) + reject(b"not json", status=400) + reject(b"[1, 2]", status=400) + + +def test_confidence_is_normalized_entropy(): + assert confidence([1.0]) == 1.0 + assert confidence([0.5, 0.5]) == pytest.approx(0.0, abs=1e-12) + p = [0.88, 0.12, 0.0] + h = -(0.88 * math.log(0.88) + 0.12 * math.log(0.12)) + assert confidence(p) == pytest.approx(1 - h / math.log(3)) + + +def test_answer_shape_and_ties(): + question = mapped("two_options").questions[0] + result = answer(question, [0.5, 0.5]) + assert result["choice"] == "delete" + assert result["type"] == "choice" + assert list(result["probabilities"]) == ["delete", "cancel"] + assert answer(question, [0.2, 0.8])["choice"] == "cancel" diff --git a/tests/cua_s1/test_text_server.py b/tests/cua_s1/test_text_server.py new file mode 100644 index 0000000..435bc22 --- /dev/null +++ b/tests/cua_s1/test_text_server.py @@ -0,0 +1,167 @@ +"""HTTP tests for the worker with a fake engine: no weights, no torch.""" + +import json +from dataclasses import dataclass + +import pytest + +# The worker's own requirements include fastapi and httpx; skip where only the +# contract tests' dependencies are installed. +pytest.importorskip("fastapi") +pytest.importorskip("httpx") +from fastapi.testclient import TestClient # noqa: E402 + +from models.cua_s1.text.server import build_app # noqa: E402 + + +@dataclass +class _Ids: + shape: tuple + + +class FakeEngine: + device = "cpu" + dtype = "float32" + + def __init__(self, tokens=100, fail=False, nan=False): + self.tokens = tokens + self.fail = fail + self.nan = nan + self.forward_calls = 0 + + def encode(self, state, question): + return {"input_ids": _Ids(shape=(1, self.tokens))} + + def score_encoded(self, inputs, n_options): + self.forward_calls += 1 + if self.fail: + raise RuntimeError("CUDA out of memory") + + @dataclass + class Scored: + probabilities: list + prompt_tokens: int + + probabilities = [0.1] * n_options + probabilities[-1] = 1.0 - 0.1 * (n_options - 1) + if self.nan: + probabilities[0] = float("nan") + return Scored(probabilities, inputs["input_ids"].shape[1]) + + +def client(engine=None, api_key=None, max_body_bytes=4 << 20, max_prompt_tokens=32768): + app = build_app( + engine or FakeEngine(), + api_key=api_key, + max_body_bytes=max_body_bytes, + max_questions=64, + max_prompt_tokens=max_prompt_tokens, + revision="r", + ) + return TestClient(app) + + +BODY = { + "model": "cua-s1-4b-0.2", + "state": "Screen", + "questions": { + "q": { + "type": "choice", + "instructions": "Pick.", + "criteria": {"a": "A", "b": "B"}, + } + }, +} + + +def test_health(): + response = client().get("/health") + assert response.status_code == 200 + assert response.json()["model"] == "cua-ai/cua-s1-4b-0.2@r:text" + assert response.json()["status"] == "ready" + assert response.json()["modality"] == "text" + + +def test_choice_answer(): + response = client().post("/v1/systemone", json=BODY) + assert response.status_code == 200, response.text + body = response.json() + assert body["answers"]["q"]["type"] == "choice" + assert body["answers"]["q"]["choice"] == "b" + assert body["usage"] == {"input_tokens": 100, "output_tokens": 0} + + +def test_chunked_upload(): + raw = json.dumps(BODY).encode() + response = client().post( + "/v1/systemone", + content=iter([raw[:10], raw[10:]]), + headers={"content-type": "application/json"}, + ) + assert response.status_code == 200, response.text + + +def test_errors(): + c = client() + bad = json.loads(json.dumps(BODY)) + bad["questions"]["q"]["type"] = "noul" + response = c.post("/v1/systemone", json=bad) + assert response.status_code == 422 + assert "'noul' is not supported" in response.json()["detail"] + assert c.post("/v1/systemone", content=b"{").status_code == 400 + + +def test_limits(): + assert client(max_body_bytes=50).post("/v1/systemone", json=BODY).status_code == 413 + raw = json.dumps(BODY).encode() + streamed = client(max_body_bytes=50).post( + "/v1/systemone", + content=iter([raw[:40], raw[40:]]), + headers={"content-type": "application/json"}, + ) + assert streamed.status_code == 413 + engine = FakeEngine(tokens=40000) + body = json.loads(json.dumps(BODY)) + body["questions"]["r"] = body["questions"]["q"] + response = client(engine).post("/v1/systemone", json=body) + assert response.status_code == 413 + assert "token limit" in response.json()["detail"] + assert engine.forward_calls == 0 + + +@pytest.mark.parametrize("engine", [FakeEngine(fail=True), FakeEngine(nan=True)]) +def test_engine_failure_is_json_500(engine): + response = client(engine).post("/v1/systemone", json=BODY) + assert response.status_code == 500 + assert response.json() == {"detail": "inference failed"} + + +def test_warmup_runs_the_request_path(): + engine = FakeEngine() + app = build_app( + engine, + api_key=None, + max_body_bytes=1 << 20, + max_questions=64, + max_prompt_tokens=32768, + revision="r", + ) + app.state.warmup() + assert engine.forward_calls == 1 + with pytest.raises(ValueError): + build_app( + FakeEngine(nan=True), + api_key=None, + max_body_bytes=1 << 20, + max_questions=64, + max_prompt_tokens=32768, + revision="r", + ).state.warmup() + + +def test_bearer_token(): + c = client(api_key="secret") + assert c.post("/v1/systemone", json=BODY).status_code == 401 + assert c.get("/health").status_code == 200 + ok = c.post("/v1/systemone", json=BODY, headers={"Authorization": "Bearer secret"}) + assert ok.status_code == 200 diff --git a/tests/cua_s1/test_text_tokenizer.py b/tests/cua_s1/test_text_tokenizer.py new file mode 100644 index 0000000..90bcd94 --- /dev/null +++ b/tests/cua_s1/test_text_tokenizer.py @@ -0,0 +1,45 @@ +"""Tokenizer checks against the pinned base model (tokenizer files only, no weights). + +Set CUA_S1_BASE to a local Qwen/Qwen3.5-4B directory to run them. +""" + +import json +import os +from pathlib import Path + +import pytest + +from models.cua_s1.text.contract import LETTERS, build_messages, map_request, parse_body + +BASE = os.environ.get("CUA_S1_BASE") +pytestmark = pytest.mark.skipif(not BASE, reason="set CUA_S1_BASE to run") + + +@pytest.fixture(scope="module") +def tokenizer(): + from transformers import AutoTokenizer + + return AutoTokenizer.from_pretrained(BASE) + + +def test_letter_ids(tokenizer): + ids = [tokenizer.encode(letter, add_special_tokens=False) for letter in LETTERS] + assert ids == [[32 + i] for i in range(26)] + + +def test_fixture_prompt(tokenizer): + inputs = json.loads( + (Path(__file__).parent / "data" / "text_inputs.json").read_text( + encoding="utf-8" + ) + ) + request = map_request(parse_body(json.dumps(inputs["fixture_positive"]).encode())) + text = tokenizer.apply_chat_template( + build_messages(request.state, request.questions[0]), + tokenize=False, + add_generation_prompt=True, + ) + assert text.endswith("<|im_start|>assistant\n\n") + ids = tokenizer(text)["input_ids"] + assert len(ids) == 218 + assert ids == tokenizer(text, add_special_tokens=False)["input_ids"] From 014bf54a5fe93bec08db9a8c0513335e9be388ce Mon Sep 17 00:00:00 2001 From: Tianyao Wu Date: Mon, 28 Sep 2026 01:49:46 +0800 Subject: [PATCH 2/5] cua_s1: return text worker answers as a JSONResponse FastAPI runs a returned dict through jsonable_encoder, which drops every key that starts with "_sa". Question names and option keys come from the request, so a question named "_sample" or an option named "_save" was missing from the answer while its tokens still counted in usage, and the choice could name an option that was not in the probabilities. Signed-off-by: Tianyao Wu --- src/models/cua_s1/text/server.py | 5 ++++- tests/cua_s1/test_text_server.py | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/models/cua_s1/text/server.py b/src/models/cua_s1/text/server.py index a272eec..cb7da3b 100644 --- a/src/models/cua_s1/text/server.py +++ b/src/models/cua_s1/text/server.py @@ -124,7 +124,10 @@ async def systemone(request: Request): try: mapped = map_request(parse_body(bytes(raw)), max_questions=max_questions) loop = asyncio.get_running_loop() - return await loop.run_in_executor(pool, decide, mapped) + # Returned as a JSONResponse: FastAPI's default encoder would drop + # every key that starts with "_sa", and question names and option + # keys come from the request. + return JSONResponse(await loop.run_in_executor(pool, decide, mapped)) except RequestError as exc: return error(exc.status, str(exc)) except Exception: diff --git a/tests/cua_s1/test_text_server.py b/tests/cua_s1/test_text_server.py index 435bc22..cd4e51d 100644 --- a/tests/cua_s1/test_text_server.py +++ b/tests/cua_s1/test_text_server.py @@ -91,6 +91,22 @@ def test_choice_answer(): assert body["usage"] == {"input_tokens": 100, "output_tokens": 0} +def test_keys_come_back_as_sent(): + body = json.loads(json.dumps(BODY)) + body["questions"] = { + "_sample": { + "type": "choice", + "instructions": "Pick.", + "criteria": {"_save": "Save", "b": "B"}, + } + } + response = client().post("/v1/systemone", json=body) + assert response.status_code == 200, response.text + answers = response.json()["answers"] + assert list(answers) == ["_sample"] + assert list(answers["_sample"]["probabilities"]) == ["_save", "b"] + + def test_chunked_upload(): raw = json.dumps(BODY).encode() response = client().post( From 20918aa41d8207c98143ce76acff3cb7d34e975b Mon Sep 17 00:00:00 2001 From: Tianyao Wu Date: Mon, 28 Sep 2026 06:10:41 +0800 Subject: [PATCH 3/5] cua_s1: build the frontend from main in the text recipe The frontend is merged (#2), so the recipe builds it from the repository root instead of the pull request branch. Signed-off-by: Tianyao Wu --- recipe/cua_s1/text.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/recipe/cua_s1/text.md b/recipe/cua_s1/text.md index 90dd738..d7cebdb 100644 --- a/recipe/cua_s1/text.md +++ b/recipe/cua_s1/text.md @@ -44,15 +44,13 @@ Oversized requests get `413`: bodies over 4 MiB, more than 64 questions, or a qu ## Start the frontend -The frontend is in [#2](https://github.com/ThinkFlowLab/system1-omni/pull/2), which is not merged yet. Build it from that pull request's branch: +Build and start the frontend from the repository root, with stable Rust installed: ```sh -git fetch origin pull/2/head:frontend-pr2 -git worktree add ../system1-omni-frontend frontend-pr2 -(cd ../system1-omni-frontend && cargo build --release --locked) +cargo build --release --locked OMNI_JEV_BIND=127.0.0.1:8080 \ OMNI_JEV_BACKEND_URL=http://127.0.0.1:8000 \ - ../system1-omni-frontend/target/release/omni-jev + ./target/release/omni-jev ``` ## Send a request From baa80d6c6328eb413613ef0b33a9f8425e9c8a55 Mon Sep 17 00:00:00 2001 From: Tianyao Wu Date: Wed, 30 Sep 2026 09:55:25 +0800 Subject: [PATCH 4/5] cua_s1: keep the text worker to the model, its HTTP worker and tests Move the HTTP worker to src/frontend/cua_s1_text.py, so src/models/cua_s1/text/ holds only the model: contract.py (request mapping, prompts, answers) and model.py (adapter checks, loading, readout; formerly engine.py and adapter.py). The upstream MIT notice moves into contract.py. The upstream comparison and latency scripts are no longer part of the change; the recipe keeps setup and launch only. Signed-off-by: Tianyao Wu --- .gitignore | 9 +- README.md | 2 +- recipe/README.md | 2 +- recipe/cua_s1/bench_text.py | 122 ----------- recipe/cua_s1/compare_text_with_upstream.py | 204 ------------------ recipe/cua_s1/requirements-text.txt | 3 +- recipe/cua_s1/text.md | 23 +- .../server.py => frontend/cua_s1_text.py} | 30 +-- src/models/cua_s1/README.md | 10 +- src/models/cua_s1/text/THIRD_PARTY_NOTICES.md | 23 -- src/models/cua_s1/text/adapter.py | 51 ----- src/models/cua_s1/text/contract.py | 31 ++- src/models/cua_s1/text/engine.py | 84 -------- src/models/cua_s1/text/model.py | 129 +++++++++++ ...est_text_adapter.py => test_text_model.py} | 2 +- tests/cua_s1/test_text_server.py | 30 +-- 16 files changed, 198 insertions(+), 557 deletions(-) delete mode 100644 recipe/cua_s1/bench_text.py delete mode 100644 recipe/cua_s1/compare_text_with_upstream.py rename src/{models/cua_s1/text/server.py => frontend/cua_s1_text.py} (91%) delete mode 100644 src/models/cua_s1/text/THIRD_PARTY_NOTICES.md delete mode 100644 src/models/cua_s1/text/adapter.py delete mode 100644 src/models/cua_s1/text/engine.py create mode 100644 src/models/cua_s1/text/model.py rename tests/cua_s1/{test_text_adapter.py => test_text_model.py} (94%) diff --git a/.gitignore b/.gitignore index 07a95a7..99ba095 100644 --- a/.gitignore +++ b/.gitignore @@ -20,12 +20,9 @@ target # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ -# Python workers and local model artifacts -__pycache__/ -.pytest_cache/ -.ruff_cache/ +# Local Python worker environment .venv/ +__pycache__/ +# Model weights downloaded by the recipes weights/ - -# macOS metadata .DS_Store diff --git a/README.md b/README.md index 76944aa..05d9d08 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Implementation code lives under `src/`; recipes and documentation stay at the re | Directory | Responsibility | | --- | --- | -| [`src/frontend/`](src/frontend/) | Rust serving code and the small engine interface. | +| [`src/frontend/`](src/frontend/) | Rust serving code, Python worker adapters, and the small engine interface. | | [`src/models/`](src/models/) | Model implementations, one directory per model: preprocessing, batching, state, execution, and output processing. | | [`src/backends/cuda/`](src/backends/cuda/) | NVIDIA GPU operations and kernel integration. | | [`src/backends/metal/`](src/backends/metal/) | Apple GPU operations and kernel integration. | diff --git a/recipe/README.md b/recipe/README.md index 875486c..48d22d5 100644 --- a/recipe/README.md +++ b/recipe/README.md @@ -3,7 +3,7 @@ - [Laya text worker](laya/README.md): start the external Python worker, connect the Rust frontend and compare direct and proxied responses. - [Cua-S1 4B 0.2 text worker](cua_s1/text.md): download the pinned weights, start - the worker, connect the Rust frontend and check the worker against upstream. + the worker and connect the Rust frontend. Recipes contain setup, launch commands and examples. Reusable implementation code belongs under `src/`. diff --git a/recipe/cua_s1/bench_text.py b/recipe/cua_s1/bench_text.py deleted file mode 100644 index f5ee9c8..0000000 --- a/recipe/cua_s1/bench_text.py +++ /dev/null @@ -1,122 +0,0 @@ -"""Send the fixed input set to a running worker, directly and through the frontend. - -For each case it checks that the frontend returns the same status, content -type and body bytes as the worker, then measures warm end-to-end latency on -both paths. Warmup requests are sent first and reported separately. Requests -are sequential (concurrency 1). - - python recipe/cua_s1/bench_text.py --direct http://127.0.0.1:8000 \ - --frontend http://127.0.0.1:8080 --warmup 3 --repeat 20 --out results.json -""" - -from __future__ import annotations - -import argparse -import json -import statistics -import time -import urllib.error -import urllib.request -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -# Ignore http_proxy and friends: the worker and the frontend are local. -OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({})) - - -def post(url: str, body: bytes, token: str | None) -> tuple[int, str, bytes, float]: - headers = {"Content-Type": "application/json"} - if token: - headers["Authorization"] = f"Bearer {token}" - request = urllib.request.Request(url + "/v1/systemone", data=body, headers=headers) - started = time.perf_counter() - try: - with OPENER.open(request, timeout=120) as response: - data = response.read() - status, ctype = response.status, response.headers.get("content-type", "") - except urllib.error.HTTPError as error: - data, status, ctype = ( - error.read(), - error.code, - error.headers.get("content-type", ""), - ) - return status, ctype, data, (time.perf_counter() - started) * 1000 - - -def pct(values: list[float], q: float) -> float: - ordered = sorted(values) - return ordered[min(len(ordered) - 1, round(q * (len(ordered) - 1)))] - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) - parser.add_argument("--direct", required=True, help="worker base URL") - parser.add_argument( - "--frontend", help="frontend base URL; omit to measure the worker only" - ) - parser.add_argument( - "--inputs", default=str(ROOT / "tests/cua_s1/data/text_inputs.json") - ) - parser.add_argument("--warmup", type=int, default=3) - parser.add_argument("--repeat", type=int, default=20) - parser.add_argument("--token", help="bearer token, if the worker requires one") - parser.add_argument("--out") - args = parser.parse_args() - - cases = json.loads(Path(args.inputs).read_text(encoding="utf-8")) - paths = {"direct": args.direct} - if args.frontend: - paths["frontend"] = args.frontend - results, mismatches = {}, 0 - for name, body in cases.items(): - raw = json.dumps(body, ensure_ascii=False).encode() - status, ctype, direct_body, _ = post(args.direct, raw, args.token) - row = {"status": status, "content_type": ctype} - if status == 200: - reply = json.loads(direct_body) - row["answers"], row["input_tokens"] = ( - reply["answers"], - reply["usage"]["input_tokens"], - ) - else: - row["body"] = direct_body.decode("utf-8", "replace")[:500] - if args.frontend: - f_status, f_ctype, f_body, _ = post(args.frontend, raw, args.token) - row["frontend_identical"] = (f_status, f_ctype, f_body) == ( - status, - ctype, - direct_body, - ) - mismatches += not row["frontend_identical"] - for label, url in paths.items(): - warm = [post(url, raw, args.token)[3] for _ in range(args.warmup)] - times = [post(url, raw, args.token)[3] for _ in range(args.repeat)] - row[label] = { - "warmup_ms": [round(t, 2) for t in warm], - "p50_ms": round(statistics.median(times), 2), - "p95_ms": round(pct(times, 0.95), 2), - "min_ms": round(min(times), 2), - "raw_ms": [round(t, 2) for t in times], - } - results[name] = row - line = f"{name}: status {status}, tokens {row.get('input_tokens')}" - for label in paths: - line += ( - f", {label} p50 {row[label]['p50_ms']} ms p95 {row[label]['p95_ms']} ms" - ) - if args.frontend: - line += f", identical {row['frontend_identical']}" - print(line, flush=True) - if args.out: - Path(args.out).write_text( - json.dumps(results, ensure_ascii=False, indent=1) + "\n" - ) - if args.frontend: - print( - f"{len(cases) - mismatches}/{len(cases)} cases byte-identical through the frontend" - ) - return 1 if mismatches else 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/recipe/cua_s1/compare_text_with_upstream.py b/recipe/cua_s1/compare_text_with_upstream.py deleted file mode 100644 index 8a50a54..0000000 --- a/recipe/cua_s1/compare_text_with_upstream.py +++ /dev/null @@ -1,204 +0,0 @@ -"""Compare the Cua-S1 worker with upstream `FourBModel` on the fixed input set. - -Needs a checkout of trycua/cua at the pinned commit (for `cua_s1.four_b` and -the jev-use chooser) and the pinned weights. The worker's model scores every -question first and is freed; then `FourBModel` is loaded with the same device -and dtype and scores the same questions. For every question it checks: - -- prompt token ids: worker vs upstream `build_prompt` plus the chat template; -- probabilities: worker vs `FourBModel.forward`, exact fp32 equality; -- for the two upstream fixtures, also worker vs the chooser's own path - (`S1DecisionModel.score`), matched by option key. - -Upstream `build_prompt` is given the worker's mapped labels, state and goal, -so the id check covers the prompt layout, chat template and tokenizer. The -request mapping itself (escaping, structured values, `null` labels) is -covered by the unit tests and, independently, by the two fixtures. - -Only one model is resident at a time. Both compute logits for every prompt -position, so the longest input (15,446 tokens) needs about 8 GB for logits in -bfloat16 and 15 GB in float32, on top of the weights. - -Run from the repository root: - - python recipe/cua_s1/compare_text_with_upstream.py --upstream ../cua \\ - --base weights/Qwen3.5-4B --adapter weights/cua-s1-4b-0.2/text --device cuda -""" - -from __future__ import annotations - -import argparse -import gc -import json -import sys -import time -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT / "src")) - -FIXTURES = { - "fixture_positive": "jev-choice-request-v1.json", - "fixture_negative": "jev-choice-negative-v1.json", -} - - -def free(device: str) -> None: - import torch - - gc.collect() - if device.startswith("cuda"): - torch.cuda.empty_cache() - - -def peak_gib(device: str) -> float | None: - import torch - - if not device.startswith("cuda"): - return None - peak = torch.cuda.max_memory_allocated() / 2**30 - torch.cuda.reset_peak_memory_stats() - return round(peak, 2) - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) - parser.add_argument( - "--upstream", required=True, help="trycua/cua checkout at the pinned commit" - ) - parser.add_argument("--base", required=True) - parser.add_argument("--adapter", required=True) - parser.add_argument("--device", default="cuda") - parser.add_argument("--dtype", default="bfloat16") - parser.add_argument( - "--inputs", default=str(ROOT / "tests/cua_s1/data/text_inputs.json") - ) - parser.add_argument("--out", help="write one JSON line per question here") - parser.add_argument( - "--no-tf32", - action="store_true", - help="disable TF32 in cuBLAS and cuDNN (use for fp32 reference runs)", - ) - args = parser.parse_args() - - upstream = Path(args.upstream) - sys.path.insert(0, str(upstream / "libs/cua-s1/python/src")) - sys.path.insert(0, str(upstream / "libs/cua-driver/examples/jev-use/python")) - import torch - - if args.no_tf32: - torch.backends.cuda.matmul.allow_tf32 = False - torch.backends.cudnn.allow_tf32 = False - from cua_s1.four_b import FourBModel, Option, assign_letters, build_prompt - from decision_models import DecisionRequest, S1DecisionModel - - from models.cua_s1.text.contract import ( - ACTION, - APP, - ROLE, - TASK_FAMILY, - map_request, - parse_body, - ) - from models.cua_s1.text.engine import TextEngine - - cases = json.loads(Path(args.inputs).read_text(encoding="utf-8")) - questions = [] - for name, body in cases.items(): - request = map_request(parse_body(json.dumps(body).encode())) - questions += [(name, request, question) for question in request.questions] - - # Pass 1: the worker. - engine = TextEngine(args.base, args.adapter, args.device, args.dtype) - print(f"worker loaded in {engine.load_seconds:.1f} s", flush=True) - worker = {} - for name, request, question in questions: - worker[name, question.name] = ( - engine.prompt_ids(request.state, question), - engine.score(request.state, question).probabilities, - ) - worker_peak = peak_gib(args.device) - del engine - free(args.device) - - # Pass 2: upstream FourBModel, and the chooser for the two fixtures. - started = time.perf_counter() - reference = FourBModel( - base_model=args.base, - lora_adapter_path=args.adapter, - device=args.device, - dtype=args.dtype, - modality="text", - ) - reference.load() - print(f"upstream loaded in {time.perf_counter() - started:.1f} s", flush=True) - fixture_dir = upstream / "libs/cua-driver/examples/jev-use/fixtures" - rows, failures = [], 0 - for name, request, question in questions: - options = [ - Option(element_id=k, role=ROLE, label=label, action=ACTION) - for k, label in zip(question.keys, question.labels, strict=True) - ] - kwargs = dict( - app=APP, - task_family=TASK_FAMILY, - ax_tree=request.state, - modality="text", - goal=question.goal or None, - ) - messages = build_prompt(assign_letters(options), **kwargs) - chat = reference._tokenizer.apply_chat_template( - messages, tokenize=False, add_generation_prompt=True - ) - upstream_ids = reference._tokenizer(chat)["input_ids"] - upstream_p = [r.probability for r in reference.forward(options, **kwargs)] - worker_ids, worker_p = worker[name, question.name] - row = { - "case": name, - "question": question.name, - "options": len(options), - "prompt_tokens": len(worker_ids), - "ids_equal": worker_ids == upstream_ids, - "probs_equal": worker_p == upstream_p, - "max_abs_diff": max( - abs(a - b) for a, b in zip(worker_p, upstream_p, strict=True) - ), - "worker": dict(zip(question.keys, worker_p, strict=True)), - "upstream": dict(zip(question.keys, upstream_p, strict=True)), - } - if name in FIXTURES: - raw = json.loads((fixture_dir / FIXTURES[name]).read_text(encoding="utf-8")) - chooser = ( - S1DecisionModel(reference, modality="text") - .score(DecisionRequest.from_validated(raw)) - .probabilities - ) - row["chooser_equal"] = all( - chooser.get(k) == p for k, p in row["worker"].items() - ) - ok = row["ids_equal"] and row["probs_equal"] and row.get("chooser_equal", True) - failures += not ok - rows.append(row) - print( - f"{'ok ' if ok else 'FAIL'} {name}/{question.name}: {len(options)} options, " - f"{len(worker_ids)} tokens, max |diff| {row['max_abs_diff']:.3g}", - flush=True, - ) - upstream_peak = peak_gib(args.device) - - if args.out: - with open(args.out, "w", encoding="utf-8") as f: - for row in rows: - f.write(json.dumps(row, ensure_ascii=False) + "\n") - if worker_peak is not None: - print(f"peak allocated: worker {worker_peak} GiB, upstream {upstream_peak} GiB") - print( - f"{len(rows) - failures}/{len(rows)} questions identical " - f"(device {args.device}, dtype {args.dtype}, torch {torch.__version__}, " - f"tf32 {'off' if args.no_tf32 else 'default'})" - ) - return 1 if failures else 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/recipe/cua_s1/requirements-text.txt b/recipe/cua_s1/requirements-text.txt index cde47c5..136ef2b 100644 --- a/recipe/cua_s1/requirements-text.txt +++ b/recipe/cua_s1/requirements-text.txt @@ -1,5 +1,6 @@ # Versions match upstream's `four-b` lock (trycua/cua libs/cua-s1/python/uv.lock -# at 0e75660ce4c2edda519e0c795fa3ad98abf4e76f), which the parity checks rely on. +# at 0e75660ce4c2edda519e0c795fa3ad98abf4e76f), the reference environment in +# src/models/cua_s1/README.md. torch==2.14.0 transformers==5.17.0 tokenizers==0.23.2 diff --git a/recipe/cua_s1/text.md b/recipe/cua_s1/text.md index d7cebdb..53be694 100644 --- a/recipe/cua_s1/text.md +++ b/recipe/cua_s1/text.md @@ -1,6 +1,6 @@ # Cua-S1 4B 0.2 text worker -This recipe runs the Cua-S1 4B 0.2 `text` adapter behind the Rust frontend. The worker lives in [`src/models/cua_s1/text/`](../../src/models/cua_s1/text/), and [`src/models/cua_s1/README.md`](../../src/models/cua_s1/README.md) documents the inference contract and the request mapping. Only `choice` questions are supported. +This recipe runs the Cua-S1 4B 0.2 `text` adapter behind the Rust frontend. The model lives in [`src/models/cua_s1/text/`](../../src/models/cua_s1/text/) and the HTTP worker in [`src/frontend/cua_s1_text.py`](../../src/frontend/cua_s1_text.py); [`src/models/cua_s1/README.md`](../../src/models/cua_s1/README.md) documents the inference contract and the request mapping. Only `choice` questions are supported. Run all commands from the repository root, on Linux with an NVIDIA GPU. @@ -33,7 +33,7 @@ To verify every file against upstream's lock, clone [trycua/cua](https://github. ## Start the worker ```sh -PYTHONPATH=src .venv/bin/python -m models.cua_s1.text.server \ +PYTHONPATH=src .venv/bin/python -m frontend.cua_s1_text \ --base weights/Qwen3.5-4B --adapter weights/cua-s1-4b-0.2/text \ --device cuda --dtype bfloat16 --host 127.0.0.1 --port 8000 ``` @@ -68,25 +68,6 @@ The answer has the Jev choice shape. On an RTX 6000 Ada in bfloat16, the respons {"model":"cua-ai/cua-s1-4b-0.2@16818868b0cc7813808aae4e87b417657046ab79:text","answers":{"pick":{"type":"choice","choice":"cancel","probabilities":{"delete":0.0024726232513785362,"cancel":0.9975274205207825},"confidence":0.9750249565060322}},"usage":{"input_tokens":153,"output_tokens":0}} ``` -## Check against upstream - -`compare_text_with_upstream.py` scores the fixed input set (`tests/cua_s1/data/text_inputs.json`) with the worker's model and then with upstream `FourBModel`, one model at a time, and compares the results. It needs the trycua/cua checkout from above and two extra packages for upstream's processor: - -```sh -.venv/bin/python -m pip install torchvision==0.29.0 pillow==11.3.0 -.venv/bin/python recipe/cua_s1/compare_text_with_upstream.py --upstream ../cua \ - --base weights/Qwen3.5-4B --adapter weights/cua-s1-4b-0.2/text --device cuda -``` - -Every question must have identical prompt token ids and identical fp32 probabilities. - -With the worker and the frontend running, `bench_text.py` checks that the frontend returns the same bytes as the worker for every input, then measures warm latency on both paths: - -```sh -.venv/bin/python recipe/cua_s1/bench_text.py --direct http://127.0.0.1:8000 \ - --frontend http://127.0.0.1:8080 --warmup 3 --repeat 20 -``` - ## Tests The contract and HTTP tests need neither weights nor a GPU. The tokenizer tests also run when `CUA_S1_BASE` points to the downloaded base model; they read only its tokenizer files: diff --git a/src/models/cua_s1/text/server.py b/src/frontend/cua_s1_text.py similarity index 91% rename from src/models/cua_s1/text/server.py rename to src/frontend/cua_s1_text.py index cb7da3b..eb2295a 100644 --- a/src/models/cua_s1/text/server.py +++ b/src/frontend/cua_s1_text.py @@ -1,9 +1,10 @@ """HTTP worker for Cua-S1 4B 0.2 (`text` adapter) behind the Rust frontend. Routes: `GET /health` and `POST /v1/systemone`. The model is loaded before the -server starts listening, and one forward pass runs at a time. +server starts listening, and one forward pass runs at a time. The model itself +is in `src/models/cua_s1/text/`. - PYTHONPATH=src python -m models.cua_s1.text.server --base --adapter + PYTHONPATH=src python -m frontend.cua_s1_text --base --adapter """ from __future__ import annotations @@ -24,7 +25,7 @@ from fastapi import FastAPI, Request from fastapi.responses import JSONResponse -from .contract import ( +from models.cua_s1.text.contract import ( ADAPTER_REVISION, MODEL_NAME, RequestError, @@ -48,7 +49,7 @@ def build_app( - engine: Any, + model: Any, *, api_key: str | None, max_body_bytes: int, @@ -80,8 +81,8 @@ def health(): "status": "ready", "modality": "text", "model": identity, - "device": engine.device, - "dtype": engine.dtype, + "device": model.device, + "dtype": model.dtype, } def decide(mapped): @@ -89,7 +90,7 @@ def decide(mapped): # before any forward pass runs. encoded = [] for question in mapped.questions: - inputs = engine.encode(mapped.state, question) + inputs = model.encode(mapped.state, question) n = int(inputs["input_ids"].shape[1]) if max_prompt_tokens and n > max_prompt_tokens: raise RequestError( @@ -100,7 +101,7 @@ def decide(mapped): encoded.append((question, inputs)) answers, prompt_tokens = {}, 0 for question, inputs in encoded: - scored = engine.score_encoded(inputs, len(question.keys)) + scored = model.score_encoded(inputs, len(question.keys)) answers[question.name] = answer(question, scored.probabilities) prompt_tokens += scored.prompt_tokens return { @@ -189,8 +190,11 @@ def main(argv: list[str] | None = None) -> None: import uvicorn - from .adapter import downloaded_revision, text_adapter_dir - from .engine import TextEngine + from models.cua_s1.text.model import ( + TextModel, + downloaded_revision, + text_adapter_dir, + ) # Fail before loading weights if this is not the text adapter. text_adapter_dir(args.adapter) @@ -212,13 +216,13 @@ def main(argv: list[str] | None = None) -> None: flush=True, ) - engine = TextEngine(args.base, args.adapter, args.device, args.dtype) + model = TextModel(args.base, args.adapter, args.device, args.dtype) print( - f"loaded in {engine.load_seconds:.1f} s on {args.device} ({args.dtype})", + f"loaded in {model.load_seconds:.1f} s on {args.device} ({args.dtype})", flush=True, ) app = build_app( - engine, + model, api_key=env("CUA_S1_API_KEY") or None, max_body_bytes=args.max_body_bytes, max_questions=args.max_questions, diff --git a/src/models/cua_s1/README.md b/src/models/cua_s1/README.md index f0712d2..8711b17 100644 --- a/src/models/cua_s1/README.md +++ b/src/models/cua_s1/README.md @@ -2,15 +2,7 @@ This directory owns Cua-S1 4B 0.2 ([#10](https://github.com/ThinkFlowLab/system1-omni/issues/10)): request mapping, prompt construction, adapter selection, execution, and the answer-letter readout. This page records the pinned upstream revisions, the inference contract an implementation must match, and how its outputs will be compared with the upstream reference. -Status: a reference worker for the `text` adapter is in [`text/`](text/). It loads the model directly through Hugging Face Transformers and PEFT and serves `/v1/systemone`; setup and checks are in [`recipe/cua_s1/text.md`](../../../recipe/cua_s1/text.md). A worker for the `multimodal` adapter is proposed in [#12](https://github.com/ThinkFlowLab/system1-omni/pull/12). - -| Path | Contents | -| --- | --- | -| `text/contract.py` | Request validation, the `/v1/systemone` mapping, prompt construction and answers. No torch imports. | -| `text/engine.py` | Model and adapter loading and the answer-letter readout. | -| `text/server.py` | The HTTP worker (`GET /health`, `POST /v1/systemone`). | -| `text/adapter.py` | Finds and checks the local `text` adapter and its downloaded revision. | -| `tests/cua_s1/test_text_*.py` (repository root) | Tests that need neither weights nor a GPU, and tokenizer checks. The fixed input set is `tests/cua_s1/data/text_inputs.json`. | +Status: a reference worker for the `text` adapter loads the model directly through Hugging Face Transformers and PEFT and serves `/v1/systemone`. The model is in [`text/`](text/) (`contract.py` for request mapping, prompts and answers; `model.py` for loading and the answer-letter readout), the HTTP worker is [`src/frontend/cua_s1_text.py`](../../frontend/cua_s1_text.py), and setup is in [`recipe/cua_s1/text.md`](../../../recipe/cua_s1/text.md). A worker for the `multimodal` adapter is proposed in [#12](https://github.com/ThinkFlowLab/system1-omni/pull/12). ## Pinned revisions diff --git a/src/models/cua_s1/text/THIRD_PARTY_NOTICES.md b/src/models/cua_s1/text/THIRD_PARTY_NOTICES.md deleted file mode 100644 index f054d43..0000000 --- a/src/models/cua_s1/text/THIRD_PARTY_NOTICES.md +++ /dev/null @@ -1,23 +0,0 @@ -The system message, prompt layout and fixed values in `contract.py`, and the two upstream fixtures converted in `tests/cua_s1/data/text_inputs.json`, come from [trycua/cua](https://github.com/trycua/cua) at `0e75660ce4c2edda519e0c795fa3ad98abf4e76f` under the following license. - -MIT License - -Copyright (c) 2025 Cua AI, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/src/models/cua_s1/text/adapter.py b/src/models/cua_s1/text/adapter.py deleted file mode 100644 index 472e533..0000000 --- a/src/models/cua_s1/text/adapter.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Locate and check the local Cua-S1 `text` adapter. No torch imports.""" - -from __future__ import annotations - -import json -import re -from pathlib import Path - - -def text_adapter_dir(adapter_root: str | Path) -> Path: - """Return the `text` adapter directory under the adapter root. - - Accepts the repository root (`/text`) or the `text/` directory - itself, and refuses the `multimodal/` adapter: PEFT only warns about keys - it cannot place, so loading the wrong adapter would otherwise go unnoticed. - """ - root = Path(adapter_root) - path = root / "text" if (root / "text" / "adapter_config.json").exists() else root - config_file = path / "adapter_config.json" - if not config_file.exists(): - raise RuntimeError(f"no adapter_config.json under {root}") - config = json.loads(config_file.read_text()) - if config.get("base_model_name_or_path") != "Qwen/Qwen3.5-4B": - raise RuntimeError(f"{config_file}: base model is not Qwen/Qwen3.5-4B") - if {"linear_fc1", "linear_fc2"} & set(config.get("target_modules") or []): - raise RuntimeError( - f"{config_file}: this is the multimodal adapter, not the text adapter" - ) - return path - - -def downloaded_revision(adapter_root: str | Path) -> str | None: - """The commit that `hf download --local-dir` recorded for the text adapter, if any. - - `hf download` keeps its metadata under the repository root, so this also - looks one level up when `adapter_root` is the `text/` directory itself. - """ - root = Path(adapter_root) - places = [(root, "text/"), (root, "")] - if root.name == "text": - places.insert(0, (root.parent, "text/")) - for base, prefix in places: - cache = base / ".cache" / "huggingface" / "download" - try: - meta = (cache / f"{prefix}adapter_model.safetensors.metadata").read_text() - first = meta.splitlines()[0].strip() - except (OSError, IndexError): - continue - if re.fullmatch(r"[0-9a-f]{40}", first): - return first - return None diff --git a/src/models/cua_s1/text/contract.py b/src/models/cua_s1/text/contract.py index a0a2d1d..5fcfcb3 100644 --- a/src/models/cua_s1/text/contract.py +++ b/src/models/cua_s1/text/contract.py @@ -16,7 +16,6 @@ ADAPTER_REPO = "cua-ai/cua-s1-4b-0.2" ADAPTER_REVISION = "16818868b0cc7813808aae4e87b417657046ab79" BASE_REPO = "Qwen/Qwen3.5-4B" -BASE_REVISION = "851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a" LETTERS = string.ascii_uppercase MAX_OPTIONS = len(LETTERS) @@ -25,8 +24,30 @@ # copied from trycua/cua at 0e75660ce4c2edda519e0c795fa3ad98abf4e76f: # `libs/cua-s1/python/src/cua_s1/four_b.py` (SYSTEM_PROMPT, build_prompt, # _describe_option) and `libs/cua-driver/examples/jev-use/python/ -# decision_models.py` (S1DecisionModel.score). MIT License, Copyright (c) 2025 -# Cua AI, Inc.; see THIRD_PARTY_NOTICES.md. +# decision_models.py` (S1DecisionModel.score), as are the two upstream fixtures +# converted in `tests/cua_s1/data/text_inputs.json`. +# +# MIT License +# +# Copyright (c) 2025 Cua AI, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. SYSTEM_PROMPT = ( "You are a one-pass computer-use decision model. You are shown the " "current state of a screen and a fixed, closed list of candidate " @@ -269,5 +290,5 @@ def answer(question: Question, probabilities: list[float]) -> dict[str, Any]: } -def model_identity(revision: str = ADAPTER_REVISION, modality: str = "text") -> str: - return f"{ADAPTER_REPO}@{revision}:{modality}" +def model_identity(revision: str = ADAPTER_REVISION) -> str: + return f"{ADAPTER_REPO}@{revision}:text" diff --git a/src/models/cua_s1/text/engine.py b/src/models/cua_s1/text/engine.py deleted file mode 100644 index cdcc351..0000000 --- a/src/models/cua_s1/text/engine.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Load Qwen3.5-4B with the Cua-S1 `text` adapter and score one prompt. - -The calls mirror upstream `cua_s1.four_b.FourBModel` (text modality): the same -model class, an unmerged PEFT adapter, the chat template with its default -generation prompt, full logits, and a fp32 softmax over the letter logits at -the last position. Keeping them the same is what makes the worker's -probabilities bitwise identical to the reference in the same environment. -""" - -from __future__ import annotations - -import time -from dataclasses import dataclass - -import torch -from peft import PeftModel -from transformers import AutoModelForCausalLM, AutoTokenizer - -from .adapter import text_adapter_dir -from .contract import LETTERS, Question, build_messages - - -@dataclass -class Scored: - probabilities: list[float] - prompt_tokens: int - - -class TextEngine: - def __init__( - self, base_model: str, adapter_root: str, device: str, dtype: str - ) -> None: - self.device = device - self.dtype = dtype - started = time.perf_counter() - self.tokenizer = AutoTokenizer.from_pretrained(base_model) - model = AutoModelForCausalLM.from_pretrained( - base_model, dtype=getattr(torch, dtype), device_map=device - ) - model = PeftModel.from_pretrained(model, str(text_adapter_dir(adapter_root))) - model.eval() - self.model = model - self.load_seconds = time.perf_counter() - started - self.letter_ids = self._letter_ids() - - def _letter_ids(self) -> list[int]: - ids = [] - for letter in LETTERS: - tokens = self.tokenizer.encode(letter, add_special_tokens=False) - if len(tokens) != 1: - raise RuntimeError(f"letter {letter!r} is not a single token: {tokens}") - ids.append(tokens[0]) - return ids - - def encode(self, state: str, question: Question): - """Tokenized prompt for one question, on CPU. - - The Qwen3.5 tokenizer adds no special tokens here (contract point 4); - the chat template already contains them. - """ - chat_text = self.tokenizer.apply_chat_template( - build_messages(state, question), tokenize=False, add_generation_prompt=True - ) - return self.tokenizer(chat_text, return_tensors="pt") - - def prompt_ids(self, state: str, question: Question) -> list[int]: - return self.encode(state, question)["input_ids"][0].tolist() - - @torch.no_grad() - def score_encoded(self, inputs, n_options: int) -> Scored: - inputs = inputs.to(self.model.device) - out = self.model(**inputs) - final_logits = out.logits[0, -1, :] - letter_ids = self.letter_ids[:n_options] - option_logits = final_logits[ - torch.tensor(letter_ids, device=final_logits.device) - ] - probabilities = torch.softmax(option_logits.float(), dim=-1).tolist() - return Scored( - probabilities=probabilities, prompt_tokens=int(inputs["input_ids"].shape[1]) - ) - - def score(self, state: str, question: Question) -> Scored: - return self.score_encoded(self.encode(state, question), len(question.keys)) diff --git a/src/models/cua_s1/text/model.py b/src/models/cua_s1/text/model.py new file mode 100644 index 0000000..9724c42 --- /dev/null +++ b/src/models/cua_s1/text/model.py @@ -0,0 +1,129 @@ +"""Load Qwen3.5-4B with the Cua-S1 `text` adapter and score one prompt. + +The calls mirror upstream `cua_s1.four_b.FourBModel` (text modality): the same +model class, an unmerged PEFT adapter, the chat template with its default +generation prompt, full logits, and a fp32 softmax over the letter logits at +the last position. Keeping them the same is what makes the worker's +probabilities bitwise identical to the reference in the same environment. + +Torch, Transformers and PEFT are imported only when a model is loaded, so the +adapter checks can run without them. +""" + +from __future__ import annotations + +import json +import re +import time +from dataclasses import dataclass +from pathlib import Path + +from .contract import BASE_REPO, LETTERS, Question, build_messages + + +def text_adapter_dir(adapter_root: str | Path) -> Path: + """Return the `text` adapter directory under the adapter root. + + Accepts the repository root (`/text`) or the `text/` directory + itself, and refuses the `multimodal/` adapter: PEFT only warns about keys + it cannot place, so loading the wrong adapter would otherwise go unnoticed. + """ + root = Path(adapter_root) + path = root / "text" if (root / "text" / "adapter_config.json").exists() else root + config_file = path / "adapter_config.json" + if not config_file.exists(): + raise RuntimeError(f"no adapter_config.json under {root}") + config = json.loads(config_file.read_text()) + if config.get("base_model_name_or_path") != BASE_REPO: + raise RuntimeError(f"{config_file}: base model is not {BASE_REPO}") + if {"linear_fc1", "linear_fc2"} & set(config.get("target_modules") or []): + raise RuntimeError( + f"{config_file}: this is the multimodal adapter, not the text adapter" + ) + return path + + +def downloaded_revision(adapter_root: str | Path) -> str | None: + """The commit that `hf download --local-dir` recorded for the text adapter, if any. + + `hf download` keeps its metadata under the repository root, so this also + looks one level up when `adapter_root` is the `text/` directory itself. + """ + root = Path(adapter_root) + places = [(root, "text/"), (root, "")] + if root.name == "text": + places.insert(0, (root.parent, "text/")) + for base, prefix in places: + cache = base / ".cache" / "huggingface" / "download" + try: + meta = (cache / f"{prefix}adapter_model.safetensors.metadata").read_text() + first = meta.splitlines()[0].strip() + except (OSError, IndexError): + continue + if re.fullmatch(r"[0-9a-f]{40}", first): + return first + return None + + +@dataclass +class Scored: + probabilities: list[float] + prompt_tokens: int + + +class TextModel: + def __init__( + self, base_model: str, adapter_root: str, device: str, dtype: str + ) -> None: + import torch + from peft import PeftModel + from transformers import AutoModelForCausalLM, AutoTokenizer + + self.device = device + self.dtype = dtype + started = time.perf_counter() + self.tokenizer = AutoTokenizer.from_pretrained(base_model) + model = AutoModelForCausalLM.from_pretrained( + base_model, dtype=getattr(torch, dtype), device_map=device + ) + model = PeftModel.from_pretrained(model, str(text_adapter_dir(adapter_root))) + model.eval() + self.model = model + self.load_seconds = time.perf_counter() - started + self.letter_ids = self._letter_ids() + + def _letter_ids(self) -> list[int]: + ids = [] + for letter in LETTERS: + tokens = self.tokenizer.encode(letter, add_special_tokens=False) + if len(tokens) != 1: + raise RuntimeError(f"letter {letter!r} is not a single token: {tokens}") + ids.append(tokens[0]) + return ids + + def encode(self, state: str, question: Question): + """Tokenized prompt for one question, on CPU. + + The Qwen3.5 tokenizer adds no special tokens here (contract point 4); + the chat template already contains them. + """ + chat_text = self.tokenizer.apply_chat_template( + build_messages(state, question), tokenize=False, add_generation_prompt=True + ) + return self.tokenizer(chat_text, return_tensors="pt") + + def score_encoded(self, inputs, n_options: int) -> Scored: + import torch + + with torch.no_grad(): + inputs = inputs.to(self.model.device) + out = self.model(**inputs) + final_logits = out.logits[0, -1, :] + letter_ids = self.letter_ids[:n_options] + option_logits = final_logits[ + torch.tensor(letter_ids, device=final_logits.device) + ] + probabilities = torch.softmax(option_logits.float(), dim=-1).tolist() + return Scored( + probabilities=probabilities, prompt_tokens=int(inputs["input_ids"].shape[1]) + ) diff --git a/tests/cua_s1/test_text_adapter.py b/tests/cua_s1/test_text_model.py similarity index 94% rename from tests/cua_s1/test_text_adapter.py rename to tests/cua_s1/test_text_model.py index e325544..8dd0fd8 100644 --- a/tests/cua_s1/test_text_adapter.py +++ b/tests/cua_s1/test_text_model.py @@ -4,7 +4,7 @@ import pytest -from models.cua_s1.text.adapter import downloaded_revision, text_adapter_dir +from models.cua_s1.text.model import downloaded_revision, text_adapter_dir REV = "16818868b0cc7813808aae4e87b417657046ab79" diff --git a/tests/cua_s1/test_text_server.py b/tests/cua_s1/test_text_server.py index cd4e51d..ed5cb7f 100644 --- a/tests/cua_s1/test_text_server.py +++ b/tests/cua_s1/test_text_server.py @@ -1,4 +1,4 @@ -"""HTTP tests for the worker with a fake engine: no weights, no torch.""" +"""HTTP tests for the worker with a fake model: no weights, no torch.""" import json from dataclasses import dataclass @@ -11,7 +11,7 @@ pytest.importorskip("httpx") from fastapi.testclient import TestClient # noqa: E402 -from models.cua_s1.text.server import build_app # noqa: E402 +from frontend.cua_s1_text import build_app # noqa: E402 @dataclass @@ -19,7 +19,7 @@ class _Ids: shape: tuple -class FakeEngine: +class FakeModel: device = "cpu" dtype = "float32" @@ -49,9 +49,9 @@ class Scored: return Scored(probabilities, inputs["input_ids"].shape[1]) -def client(engine=None, api_key=None, max_body_bytes=4 << 20, max_prompt_tokens=32768): +def client(model=None, api_key=None, max_body_bytes=4 << 20, max_prompt_tokens=32768): app = build_app( - engine or FakeEngine(), + model or FakeModel(), api_key=api_key, max_body_bytes=max_body_bytes, max_questions=64, @@ -136,26 +136,26 @@ def test_limits(): headers={"content-type": "application/json"}, ) assert streamed.status_code == 413 - engine = FakeEngine(tokens=40000) + model = FakeModel(tokens=40000) body = json.loads(json.dumps(BODY)) body["questions"]["r"] = body["questions"]["q"] - response = client(engine).post("/v1/systemone", json=body) + response = client(model).post("/v1/systemone", json=body) assert response.status_code == 413 assert "token limit" in response.json()["detail"] - assert engine.forward_calls == 0 + assert model.forward_calls == 0 -@pytest.mark.parametrize("engine", [FakeEngine(fail=True), FakeEngine(nan=True)]) -def test_engine_failure_is_json_500(engine): - response = client(engine).post("/v1/systemone", json=BODY) +@pytest.mark.parametrize("model", [FakeModel(fail=True), FakeModel(nan=True)]) +def test_model_failure_is_json_500(model): + response = client(model).post("/v1/systemone", json=BODY) assert response.status_code == 500 assert response.json() == {"detail": "inference failed"} def test_warmup_runs_the_request_path(): - engine = FakeEngine() + model = FakeModel() app = build_app( - engine, + model, api_key=None, max_body_bytes=1 << 20, max_questions=64, @@ -163,10 +163,10 @@ def test_warmup_runs_the_request_path(): revision="r", ) app.state.warmup() - assert engine.forward_calls == 1 + assert model.forward_calls == 1 with pytest.raises(ValueError): build_app( - FakeEngine(nan=True), + FakeModel(nan=True), api_key=None, max_body_bytes=1 << 20, max_questions=64, From cfbccc4d2694f5dfdd7993a685482347e541e851 Mon Sep 17 00:00:00 2001 From: Tianyao Wu Date: Wed, 30 Sep 2026 17:12:01 +0800 Subject: [PATCH 5/5] cua_s1: trim the text worker to the reference path Keep the model, its HTTP worker and the tests that exercise them. The checked-in input set, revision detection, extra flags, bearer auth and duplicated validation go; the limits become constants. The README now describes the worker as the correctness reference for native execution. Signed-off-by: Tianyao Wu --- recipe/cua_s1/text.md | 54 ++---- src/frontend/cua_s1_text.py | 239 ++++++----------------- src/models/cua_s1/README.md | 4 +- src/models/cua_s1/text/contract.py | 270 +++++++------------------- src/models/cua_s1/text/model.py | 140 +++----------- tests/cua_s1/data/text_inputs.json | 289 ---------------------------- tests/cua_s1/test_text_contract.py | 271 ++++++++++++-------------- tests/cua_s1/test_text_model.py | 36 ---- tests/cua_s1/test_text_server.py | 180 ++++------------- tests/cua_s1/test_text_tokenizer.py | 45 ----- 10 files changed, 323 insertions(+), 1205 deletions(-) delete mode 100644 tests/cua_s1/data/text_inputs.json delete mode 100644 tests/cua_s1/test_text_model.py delete mode 100644 tests/cua_s1/test_text_tokenizer.py diff --git a/recipe/cua_s1/text.md b/recipe/cua_s1/text.md index 53be694..1fcfcb1 100644 --- a/recipe/cua_s1/text.md +++ b/recipe/cua_s1/text.md @@ -1,78 +1,46 @@ # Cua-S1 4B 0.2 text worker -This recipe runs the Cua-S1 4B 0.2 `text` adapter behind the Rust frontend. The model lives in [`src/models/cua_s1/text/`](../../src/models/cua_s1/text/) and the HTTP worker in [`src/frontend/cua_s1_text.py`](../../src/frontend/cua_s1_text.py); [`src/models/cua_s1/README.md`](../../src/models/cua_s1/README.md) documents the inference contract and the request mapping. Only `choice` questions are supported. +This recipe runs the Cua-S1 4B 0.2 `text` adapter through Transformers and PEFT behind the Rust frontend. It is the correctness reference for native execution. The model is in [`src/models/cua_s1/text/`](../../src/models/cua_s1/text/), the HTTP worker in [`src/frontend/cua_s1_text.py`](../../src/frontend/cua_s1_text.py), and [`src/models/cua_s1/README.md`](../../src/models/cua_s1/README.md) documents the contract. Only `choice` questions are supported. -Run all commands from the repository root, on Linux with an NVIDIA GPU. - -## Install - -Use Python 3.12. The pinned versions match the upstream reference environment: +Run the commands from the repository root, on Linux with an NVIDIA GPU and Python 3.12. The pinned versions match the upstream reference environment: ```sh python3.12 -m venv .venv .venv/bin/python -m pip install -r recipe/cua_s1/requirements-text.txt -``` - -## Download the weights - -Download the pinned revisions (about 9.5 GB) into `weights/`: - -```sh .venv/bin/hf download Qwen/Qwen3.5-4B \ --revision 851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a --local-dir weights/Qwen3.5-4B .venv/bin/hf download cua-ai/cua-s1-4b-0.2 \ --revision 16818868b0cc7813808aae4e87b417657046ab79 --local-dir weights/cua-s1-4b-0.2 ``` -To verify every file against upstream's lock, clone [trycua/cua](https://github.com/trycua/cua) next to this repository, check out `0e75660ce4c2edda519e0c795fa3ad98abf4e76f`, and run: - -```sh -.venv/bin/python ../cua/libs/cua-s1/ci/fetch_pinned_weights.py --dest weights --verify-only -``` +Upstream's `libs/cua-s1/ci/fetch_pinned_weights.py --dest weights --verify-only` (in [trycua/cua](https://github.com/trycua/cua) at `0e75660ce4c2edda519e0c795fa3ad98abf4e76f`) checks every downloaded file against upstream's lock. -## Start the worker +Start the worker, which runs one warmup decision before it listens, then the frontend: ```sh PYTHONPATH=src .venv/bin/python -m frontend.cua_s1_text \ - --base weights/Qwen3.5-4B --adapter weights/cua-s1-4b-0.2/text \ - --device cuda --dtype bfloat16 --host 127.0.0.1 --port 8000 -``` - -The worker loads the model and runs one warmup decision before it starts listening, so `GET /health` answers only once requests can be served; it then returns `{"status": "ready", "modality": "text", ...}`. The log reports load and warmup times separately. The worker refuses the `multimodal/` adapter and reports the adapter revision that `hf download` recorded. Every flag can also be set through an environment variable: `CUA_S1_BASE`, `CUA_S1_ADAPTER`, `CUA_S1_ADAPTER_REVISION`, `CUA_S1_DEVICE`, `CUA_S1_DTYPE`, `CUA_S1_HOST`, `CUA_S1_PORT`, `CUA_S1_MAX_BODY_BYTES`, `CUA_S1_MAX_QUESTIONS` and `CUA_S1_MAX_PROMPT_TOKENS`. `--adapter-revision` only sets the revision reported in `model` when the download metadata is missing; a value that contradicts the metadata stops the worker. Set `CUA_S1_API_KEY` to require `Authorization: Bearer ` on `/v1/systemone`. - -Oversized requests get `413`: bodies over 4 MiB, more than 64 questions, or a question whose prompt is over 16,384 tokens (`--max-body-bytes`, `--max-questions`, `--max-prompt-tokens`). The worker computes logits for every prompt position, as upstream does, so memory grows with prompt length: serving the 15,446-token test input in bfloat16 peaked at about 21.3 GiB in use on the card. Requests run one at a time, and the frontend gives up after 60 seconds. - -## Start the frontend - -Build and start the frontend from the repository root, with stable Rust installed: - -```sh + --base weights/Qwen3.5-4B --adapter weights/cua-s1-4b-0.2/text --port 8000 cargo build --release --locked -OMNI_JEV_BIND=127.0.0.1:8080 \ -OMNI_JEV_BACKEND_URL=http://127.0.0.1:8000 \ - ./target/release/omni-jev +OMNI_JEV_BIND=127.0.0.1:8080 OMNI_JEV_BACKEND_URL=http://127.0.0.1:8000 ./target/release/omni-jev ``` -## Send a request +Requests run one at a time. Bodies over 4 MiB, more than 64 questions, or a prompt over 16,384 tokens get `413`. The worker computes logits for every position, as upstream does, so memory grows with prompt length: the 15,446-token test input peaked at about 21.3 GiB in bfloat16. ```sh -curl http://127.0.0.1:8080/health curl http://127.0.0.1:8080/v1/systemone \ -H 'Content-Type: application/json' \ -d '{"model":"cua-s1-4b-0.2","state":"Dialog: Delete 3 files permanently? Buttons: Delete, Cancel","questions":{"pick":{"type":"choice","instructions":"Keep the files.","criteria":{"delete":"Click Delete","cancel":"Click Cancel"}}}}' ``` -The answer has the Jev choice shape. On an RTX 6000 Ada in bfloat16, the response is: +On an RTX 6000 Ada in bfloat16, the response is: ```json {"model":"cua-ai/cua-s1-4b-0.2@16818868b0cc7813808aae4e87b417657046ab79:text","answers":{"pick":{"type":"choice","choice":"cancel","probabilities":{"delete":0.0024726232513785362,"cancel":0.9975274205207825},"confidence":0.9750249565060322}},"usage":{"input_tokens":153,"output_tokens":0}} ``` -## Tests - -The contract and HTTP tests need neither weights nor a GPU. The tokenizer tests also run when `CUA_S1_BASE` points to the downloaded base model; they read only its tokenizer files: +The tests need neither weights nor a GPU; with `CUA_S1_BASE=weights/Qwen3.5-4B` they also check the tokenizer: ```sh -.venv/bin/python -m pip install pytest -CUA_S1_BASE=weights/Qwen3.5-4B PYTHONPATH=src .venv/bin/python -m pytest tests/cua_s1/test_text_*.py +.venv/bin/python -m pip install pytest httpx +PYTHONPATH=src .venv/bin/python -m pytest tests/cua_s1 ``` diff --git a/src/frontend/cua_s1_text.py b/src/frontend/cua_s1_text.py index eb2295a..ef3a330 100644 --- a/src/frontend/cua_s1_text.py +++ b/src/frontend/cua_s1_text.py @@ -1,21 +1,13 @@ -"""HTTP worker for Cua-S1 4B 0.2 (`text` adapter) behind the Rust frontend. +"""HTTP worker for Cua-S1 4B 0.2 (`text` adapter): `GET /health` and `POST /v1/systemone`. -Routes: `GET /health` and `POST /v1/systemone`. The model is loaded before the -server starts listening, and one forward pass runs at a time. The model itself -is in `src/models/cua_s1/text/`. - - PYTHONPATH=src python -m frontend.cua_s1_text --base --adapter +PYTHONPATH=src python -m frontend.cua_s1_text --base --adapter /text """ from __future__ import annotations import argparse import asyncio -import hmac -import json -import os import sys -import time import traceback from concurrent.futures import ThreadPoolExecutor from typing import Any @@ -26,214 +18,91 @@ from fastapi.responses import JSONResponse from models.cua_s1.text.contract import ( - ADAPTER_REVISION, - MODEL_NAME, + MODEL_ID, RequestError, answer, map_request, - model_identity, parse_body, ) -WARMUP_REQUEST = { - "model": MODEL_NAME, - "state": "Dialog: 'Update installed.' Button: OK", - "questions": { - "warmup": { - "type": "choice", - "instructions": "Close the dialog.", - "criteria": {"ok": "Click OK", "wait": "Wait"}, - } - }, -} - - -def build_app( - model: Any, - *, - api_key: str | None, - max_body_bytes: int, - max_questions: int, - max_prompt_tokens: int, - revision: str, -): - app = FastAPI() - pool = ThreadPoolExecutor(max_workers=1) - identity = model_identity(revision) - expected_auth = ( - f"Bearer {api_key}".encode("utf-8", "surrogateescape") if api_key else b"" - ) - - def error(status: int, message: str) -> JSONResponse: - return JSONResponse({"detail": message}, status_code=status) - - def authorized(request: Request) -> bool: - if not api_key: - return True - supplied = request.headers.get("authorization", "").encode( - "utf-8", "surrogateescape" - ) - return hmac.compare_digest(supplied, expected_auth) +MAX_BODY_BYTES = 4 << 20 +MAX_PROMPT_TOKENS = 16384 +WARMUP = ( + b'{"model": "cua-s1-4b-0.2", "state": "Dialog: Update installed.", "questions": {"q":' + b' {"type": "choice", "instructions": "Close it.", "criteria": {"ok": "OK", "wait": "Wait"}}}}' +) - @app.get("/health") - def health(): - return { - "status": "ready", - "modality": "text", - "model": identity, - "device": model.device, - "dtype": model.dtype, - } - def decide(mapped): - # Tokenize every question first, so an over-long prompt is rejected - # before any forward pass runs. - encoded = [] - for question in mapped.questions: - inputs = model.encode(mapped.state, question) - n = int(inputs["input_ids"].shape[1]) - if max_prompt_tokens and n > max_prompt_tokens: +def build_app(model: Any) -> FastAPI: + app = FastAPI() + pool = ThreadPoolExecutor(max_workers=1) # one forward pass at a time + + def decide(raw: bytes) -> dict[str, Any]: + state, questions = map_request(parse_body(raw)) + encoded = [model.encode(state, q) for q in questions] + tokens = [int(x["input_ids"].shape[1]) for x in encoded] + for q, n in zip(questions, tokens): # before any forward pass + if n > MAX_PROMPT_TOKENS: raise RequestError( - f"question {question.name!r}: prompt is {n} tokens, " - f"over the {max_prompt_tokens}-token limit", - status=413, + f"question {q.name!r}: {n} prompt tokens, over {MAX_PROMPT_TOKENS}", + 413, ) - encoded.append((question, inputs)) - answers, prompt_tokens = {}, 0 - for question, inputs in encoded: - scored = model.score_encoded(inputs, len(question.keys)) - answers[question.name] = answer(question, scored.probabilities) - prompt_tokens += scored.prompt_tokens + answers = { + q.name: answer(q, model.score(x, len(q.keys))) + for q, x in zip(questions, encoded) + } return { - "model": identity, + "model": MODEL_ID, "answers": answers, - "usage": {"input_tokens": prompt_tokens, "output_tokens": 0}, + "usage": {"input_tokens": sum(tokens), "output_tokens": 0}, } + @app.get("/health") + def health(): + return {"status": "ready", "model": MODEL_ID} + @app.post("/v1/systemone") async def systemone(request: Request): - if not authorized(request): - return error(401, "invalid or missing bearer token") - length = request.headers.get("content-length") - if length and length.isdigit() and int(length) > max_body_bytes: - return error(413, "request body too large") raw = bytearray() async for chunk in request.stream(): - raw.extend(chunk) - if len(raw) > max_body_bytes: - return error(413, "request body too large") + raw += chunk + if len(raw) > MAX_BODY_BYTES: + return JSONResponse({"detail": "request body too large"}, 413) try: - mapped = map_request(parse_body(bytes(raw)), max_questions=max_questions) - loop = asyncio.get_running_loop() - # Returned as a JSONResponse: FastAPI's default encoder would drop - # every key that starts with "_sa", and question names and option - # keys come from the request. - return JSONResponse(await loop.run_in_executor(pool, decide, mapped)) - except RequestError as exc: - return error(exc.status, str(exc)) + # JSONResponse, not FastAPI's encoder, which drops keys starting with "_sa". + return JSONResponse( + await asyncio.get_running_loop().run_in_executor(pool, decide, raw) + ) + except RequestError as error: + return JSONResponse({"detail": str(error)}, error.status) except Exception: traceback.print_exc(file=sys.stderr) - return error(500, "inference failed") + return JSONResponse({"detail": "inference failed"}, 500) - def warmup() -> None: - """Run one decision on the worker thread through the full request path.""" - mapped = map_request(WARMUP_REQUEST) - json.dumps(pool.submit(decide, mapped).result(), allow_nan=False) - - app.state.warmup = warmup + # One decision on the worker thread before listening, so the first request does not + # pay for first-call setup there. + app.state.warmup = lambda: pool.submit(decide, WARMUP).result() return app -def main(argv: list[str] | None = None) -> None: - env = os.environ.get +def main() -> None: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--base", required=True, help="local Qwen/Qwen3.5-4B directory") parser.add_argument( - "--base", - default=env("CUA_S1_BASE"), - help="local Qwen/Qwen3.5-4B directory (env CUA_S1_BASE)", - ) - parser.add_argument( - "--adapter", - default=env("CUA_S1_ADAPTER"), - help="local cua-ai/cua-s1-4b-0.2 directory (env CUA_S1_ADAPTER)", - ) - parser.add_argument( - "--adapter-revision", - default=env("CUA_S1_ADAPTER_REVISION"), - help="adapter revision to report when the download metadata is missing", - ) - parser.add_argument("--device", default=env("CUA_S1_DEVICE", "cuda")) - parser.add_argument( - "--dtype", - default=env("CUA_S1_DTYPE", "bfloat16"), - choices=["bfloat16", "float16", "float32"], - ) - parser.add_argument("--host", default=env("CUA_S1_HOST", "127.0.0.1")) - parser.add_argument("--port", type=int, default=int(env("CUA_S1_PORT", "8000"))) - parser.add_argument( - "--max-body-bytes", - type=int, - default=int(env("CUA_S1_MAX_BODY_BYTES", str(4 << 20))), - ) - parser.add_argument( - "--max-questions", type=int, default=int(env("CUA_S1_MAX_QUESTIONS", "64")) - ) - parser.add_argument( - "--max-prompt-tokens", - type=int, - default=int(env("CUA_S1_MAX_PROMPT_TOKENS", "16384")), - help="per question; 0 disables the check", + "--adapter", required=True, help="local text/ adapter directory" ) - args = parser.parse_args(argv) - if not args.base or not args.adapter: - parser.error("--base and --adapter are required") + parser.add_argument("--device", default="cuda") + parser.add_argument("--dtype", default="bfloat16", choices=["bfloat16", "float32"]) + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8000) + args = parser.parse_args() import uvicorn - from models.cua_s1.text.model import ( - TextModel, - downloaded_revision, - text_adapter_dir, - ) + from models.cua_s1.text.model import TextModel - # Fail before loading weights if this is not the text adapter. - text_adapter_dir(args.adapter) - detected = downloaded_revision(args.adapter) - if detected and args.adapter_revision and detected != args.adapter_revision: - parser.error( - f"--adapter-revision {args.adapter_revision} does not match the " - f"downloaded revision {detected}" - ) - revision = detected or args.adapter_revision or ADAPTER_REVISION - if revision != ADAPTER_REVISION: - print( - f"warning: adapter revision {revision} is not the pinned {ADAPTER_REVISION}", - flush=True, - ) - if not detected: - print( - "note: no download metadata under --adapter; the adapter revision is not verified", - flush=True, - ) - - model = TextModel(args.base, args.adapter, args.device, args.dtype) - print( - f"loaded in {model.load_seconds:.1f} s on {args.device} ({args.dtype})", - flush=True, - ) - app = build_app( - model, - api_key=env("CUA_S1_API_KEY") or None, - max_body_bytes=args.max_body_bytes, - max_questions=args.max_questions, - max_prompt_tokens=args.max_prompt_tokens, - revision=revision, - ) - # One decision before listening, so the first real request does not pay - # for lazy weight loading or first-call kernel setup on the worker thread. - started = time.perf_counter() + app = build_app(TextModel(args.base, args.adapter, args.device, args.dtype)) app.state.warmup() - print(f"warmed up in {time.perf_counter() - started:.1f} s", flush=True) uvicorn.run(app, host=args.host, port=args.port, log_level="warning") diff --git a/src/models/cua_s1/README.md b/src/models/cua_s1/README.md index 8711b17..aa0e4be 100644 --- a/src/models/cua_s1/README.md +++ b/src/models/cua_s1/README.md @@ -2,7 +2,7 @@ This directory owns Cua-S1 4B 0.2 ([#10](https://github.com/ThinkFlowLab/system1-omni/issues/10)): request mapping, prompt construction, adapter selection, execution, and the answer-letter readout. This page records the pinned upstream revisions, the inference contract an implementation must match, and how its outputs will be compared with the upstream reference. -Status: a reference worker for the `text` adapter loads the model directly through Hugging Face Transformers and PEFT and serves `/v1/systemone`. The model is in [`text/`](text/) (`contract.py` for request mapping, prompts and answers; `model.py` for loading and the answer-letter readout), the HTTP worker is [`src/frontend/cua_s1_text.py`](../../frontend/cua_s1_text.py), and setup is in [`recipe/cua_s1/text.md`](../../../recipe/cua_s1/text.md). A worker for the `multimodal` adapter is proposed in [#12](https://github.com/ThinkFlowLab/system1-omni/pull/12). +Status: a reference worker for the `text` adapter loads the model through Hugging Face Transformers and PEFT: [`text/`](text/), served by [`src/frontend/cua_s1_text.py`](../../frontend/cua_s1_text.py), with setup in [`recipe/cua_s1/text.md`](../../../recipe/cua_s1/text.md). It is the correctness reference for native execution, which starts with a minimal `text` prefill path on CUDA. The `multimodal` adapter is deferred; see [Not covered yet](#not-covered-yet). ## Pinned revisions @@ -88,7 +88,7 @@ The status is `422` when a well-formed request cannot be answered: ## Validation -**Inputs.** The fixed input set is upstream's two checked-in fixtures, converted to `/v1/systemone` requests with the chooser's rendered regions as `state`, plus `/v1/systemone` choice requests, in `tests/cua_s1/data/text_inputs.json`. These cover 1 to 26 options, short and long states, string and structured `state`, `instructions` and `criteria`, `null` criteria, non-ASCII text, and text that spells a special token. Each input is scored once per configuration. +**Inputs.** The fixed input set is upstream's two checked-in fixtures, converted to `/v1/systemone` requests with the chooser's rendered regions as `state`, plus `/v1/systemone` choice requests. These cover 1 to 26 options, short and long states, string and structured `state`, `instructions` and `criteria`, `null` criteria, non-ASCII text, and text that spells a special token. Each input is scored once per configuration. **Tolerances.** These are declared before any comparison is run: diff --git a/src/models/cua_s1/text/contract.py b/src/models/cua_s1/text/contract.py index 5fcfcb3..326e758 100644 --- a/src/models/cua_s1/text/contract.py +++ b/src/models/cua_s1/text/contract.py @@ -1,31 +1,22 @@ -"""Request mapping, prompt construction and answers for Cua-S1 4B 0.2. - -This module follows the contract in `src/models/cua_s1/README.md`. It has no -torch or Transformers imports, so it can be tested without weights. +"""Request mapping, prompts and answers for Cua-S1 4B 0.2, following +`src/models/cua_s1/README.md`. No torch imports, so it can be tested without weights. """ from __future__ import annotations import json import math -import string from dataclasses import dataclass from typing import Any MODEL_NAME = "cua-s1-4b-0.2" -ADAPTER_REPO = "cua-ai/cua-s1-4b-0.2" -ADAPTER_REVISION = "16818868b0cc7813808aae4e87b417657046ab79" -BASE_REPO = "Qwen/Qwen3.5-4B" - -LETTERS = string.ascii_uppercase -MAX_OPTIONS = len(LETTERS) +MODEL_ID = "cua-ai/cua-s1-4b-0.2@16818868b0cc7813808aae4e87b417657046ab79:text" +LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" +MAX_QUESTIONS = 64 -# The system message, the user message layout and the fixed values below are -# copied from trycua/cua at 0e75660ce4c2edda519e0c795fa3ad98abf4e76f: -# `libs/cua-s1/python/src/cua_s1/four_b.py` (SYSTEM_PROMPT, build_prompt, -# _describe_option) and `libs/cua-driver/examples/jev-use/python/ -# decision_models.py` (S1DecisionModel.score), as are the two upstream fixtures -# converted in `tests/cua_s1/data/text_inputs.json`. +# The system message and the user message layout are copied from trycua/cua at +# 0e75660ce4c2edda519e0c795fa3ad98abf4e76f (`libs/cua-s1/python/src/cua_s1/four_b.py` +# and `libs/cua-driver/examples/jev-use/python/decision_models.py`). # # MIT License # @@ -55,205 +46,100 @@ "one option: the single best next action to take. Answer with ONLY that " "option's letter -- no words, no punctuation, no explanation." ) -APP = "Cua Driver" -TASK_FAMILY = "closed-candidate decision" -ROLE = "Decision" -ACTION = "select" class RequestError(ValueError): - """A request the worker rejects. `status` is the HTTP status to return.""" - def __init__(self, message: str, status: int = 422) -> None: super().__init__(message) self.status = status -def _object_pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]: - obj: dict[str, Any] = {} +@dataclass(frozen=True) +class Question: + name: str + goal: str + keys: tuple[str, ...] + labels: tuple[str, ...] + + +def _unique_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + obj = {} for key, value in pairs: if key in obj: - raise RequestError(f"duplicate key {key!r} in a JSON object", status=400) + raise RequestError(f"duplicate key {key!r}", 400) obj[key] = value return obj -def _reject_constant(name: str) -> Any: - raise RequestError(f"{name} is not valid JSON", status=400) - - -def _finite_float(text: str) -> float: - value = float(text) - if not math.isfinite(value): - raise RequestError(f"number {text} is out of range", status=400) - return value - - -def _check_unicode(value: Any) -> None: - """Reject lone surrogates (for example a `\\ud800` escape): they cannot be - encoded as UTF-8, so they cannot be tokenized or echoed back.""" - if isinstance(value, str): - value.encode("utf-8") - elif isinstance(value, dict): - for key, item in value.items(): - key.encode("utf-8") - _check_unicode(item) - elif isinstance(value, list): - for item in value: - _check_unicode(item) - - def parse_body(raw: bytes) -> dict[str, Any]: - """Decode a request body, keeping key order and rejecting duplicate keys.""" try: - text = raw.decode("utf-8") - body = json.loads( - text, - object_pairs_hook=_object_pairs, - parse_constant=_reject_constant, - parse_float=_finite_float, - ) - _check_unicode(body) - except RequestError: - raise - except RecursionError as error: - raise RequestError("request body is nested too deeply", status=400) from error - except UnicodeError as error: - raise RequestError( - "request body must be valid UTF-8 text", status=400 - ) from error - except ValueError as error: - # json.JSONDecodeError, a UTF-8 byte order mark, or an integer too long - # for Python to convert. - raise RequestError("request body must be valid JSON", status=400) from error + body = json.loads(raw.decode(), object_pairs_hook=_unique_keys) + # NaN, Infinity, numbers out of range and lone surrogates fail here. + json.dumps(body, ensure_ascii=False, allow_nan=False).encode() + except (ValueError, RecursionError) as error: + if isinstance(error, RequestError): + raise + raise RequestError(f"request body is not valid JSON: {error}", 400) from error if not isinstance(body, dict): - raise RequestError("request body must be a JSON object", status=400) + raise RequestError("request body must be a JSON object", 400) return body -def as_text(value: Any) -> str: - """Render `state` or `instructions` as prompt text. - - A string is used as is; an object or array is serialized the way Python's - `json.dumps(value, ensure_ascii=False)` does. - """ - if isinstance(value, str): - return value - return json.dumps(value, ensure_ascii=False) - - -def escape_label(value: str) -> str: - """Escape an option label the way upstream's chooser does.""" - return json.dumps(value, ensure_ascii=False)[1:-1] - - -@dataclass(frozen=True) -class Question: - """One `choice` question mapped onto the prompt fields.""" - - name: str - goal: str - keys: tuple[str, ...] - labels: tuple[str, ...] - - -@dataclass(frozen=True) -class Request: - state: str - questions: tuple[Question, ...] - - -def _check_json_value(value: Any, where: str, allow_null: bool) -> None: - if value is None: - if not allow_null: - raise RequestError(f"{where} must not be null") - return - if isinstance(value, bool) or isinstance(value, (int, float)): - raise RequestError(f"{where} must be a string, an object or an array") +def _text(value: Any, where: str) -> str: + """A string as is; an object or array as Python's json.dumps writes it.""" if not isinstance(value, (str, dict, list)): raise RequestError(f"{where} must be a string, an object or an array") + return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False) -def map_request(body: dict[str, Any], *, max_questions: int = 64) -> Request: - """Validate a `/v1/systemone` body and map it onto prompt fields.""" - model = body.get("model") - if model != MODEL_NAME: +def map_request(body: dict[str, Any]) -> tuple[str, list[Question]]: + if body.get("model") != MODEL_NAME: raise RequestError(f"'model' must be {MODEL_NAME!r}") - - if "state" not in body: - raise RequestError("'state' is required") - state_value = body["state"] - _check_json_value(state_value, "'state'", allow_null=False) - if state_value in ("", {}, []): + if body.get("state") in ("", {}, []): raise RequestError("'state' must not be empty") - state = as_text(state_value) - + state = _text(body.get("state"), "'state'") questions = body.get("questions") if not isinstance(questions, dict) or not questions: raise RequestError("'questions' must be a non-empty object") - if len(questions) > max_questions: - raise RequestError( - f"too many questions ({len(questions)} > {max_questions})", status=413 - ) - - # Check every question type before the per-question checks, so a `score` - # or `noul` question anywhere rejects the whole request with that reason. - for name, question in questions.items(): - if not isinstance(question, dict): - raise RequestError(f"question {name!r} must be an object") - kind = question.get("type") - if kind in ("score", "noul"): - raise RequestError( - f"question {name!r}: type {kind!r} is not supported; " - "Cua-S1 4B 0.2 answers 'choice' questions only" - ) - if kind != "choice": - raise RequestError(f"question {name!r}: unknown type {kind!r}") - + if len(questions) > MAX_QUESTIONS: + raise RequestError(f"more than {MAX_QUESTIONS} questions", 413) mapped = [] - for name, question in questions.items(): + for name, q in questions.items(): where = f"question {name!r}" - if "instructions" not in question: + if not isinstance(q, dict): + raise RequestError(f"{where} must be an object") + if q.get("type") in ("score", "noul"): + raise RequestError(f"{where}: type {q['type']!r} is not supported") + if q.get("type") != "choice": + raise RequestError(f"{where}: unknown type {q.get('type')!r}") + if "instructions" not in q: raise RequestError(f"{where}: 'instructions' is required") - instructions = question["instructions"] - _check_json_value(instructions, f"{where}: 'instructions'", allow_null=True) - goal = "" if instructions is None else as_text(instructions) - - criteria = question.get("criteria") - if not isinstance(criteria, dict): - raise RequestError(f"{where}: 'criteria' must be an object") - if not criteria: - raise RequestError(f"{where}: 'criteria' must have at least one option") - if len(criteria) > MAX_OPTIONS: + goal = "" if q["instructions"] is None else _text(q["instructions"], where) + criteria = q.get("criteria") + if not isinstance(criteria, dict) or not 1 <= len(criteria) <= len(LETTERS): raise RequestError( - f"{where}: {len(criteria)} options; at most {MAX_OPTIONS} are supported" + f"{where}: 'criteria' must be an object with 1 to 26 options" ) - keys, labels = [], [] - for key, value in criteria.items(): - _check_json_value(value, f"{where}: option {key!r}", allow_null=True) - if value is None: - text = key - else: - text = as_text(value) - keys.append(key) - labels.append(escape_label(text)) - mapped.append( - Question(name=name, goal=goal, keys=tuple(keys), labels=tuple(labels)) + labels = tuple( + json.dumps( + key if value is None else _text(value, f"{where}: {key!r}"), + ensure_ascii=False, + )[1:-1] + for key, value in criteria.items() ) - return Request(state=state, questions=tuple(mapped)) + mapped.append(Question(name, goal, tuple(criteria), labels)) + return state, mapped def build_messages(state: str, question: Question) -> list[dict[str, str]]: - """Chat messages for one question, matching upstream `build_prompt` (text).""" - option_lines = "\n".join( - f'{letter}. {ROLE} "{label}" -> {ACTION}' - for letter, label in zip(LETTERS, question.labels, strict=False) + options = "\n".join( + f'{letter}. Decision "{label}" -> select' + for letter, label in zip(LETTERS, question.labels) ) user = ( (f"Goal: {question.goal}\n\n" if question.goal else "") - + f"App: {APP}\nTask family: {TASK_FAMILY}\n\n" - + f"Accessibility tree:\n{state}\n\n" - + f"Options:\n{option_lines}\n\nAnswer with a single letter." + + "App: Cua Driver\nTask family: closed-candidate decision\n\n" + + f"Accessibility tree:\n{state}\n\nOptions:\n{options}\n\nAnswer with a single letter." ) return [ {"role": "system", "content": SYSTEM_PROMPT}, @@ -261,34 +147,14 @@ def build_messages(state: str, question: Question) -> list[dict[str, str]]: ] -def confidence(probabilities: list[float]) -> float: - """Normalized entropy, `1 - H(p) / ln(n)`, as the LAYA worker reports it.""" - n = len(probabilities) - if n < 2: - return 1.0 - entropy = -sum(p * math.log(min(max(p, 1e-12), 1.0)) for p in probabilities) - return min(max(1.0 - entropy / math.log(n), 0.0), 1.0) - - def answer(question: Question, probabilities: list[float]) -> dict[str, Any]: - """The Jev choice answer. Ties go to the earliest option.""" - if len(probabilities) != len(question.keys) or not all( - math.isfinite(p) and 0.0 <= p <= 1.0 for p in probabilities - ): - raise ValueError(f"model returned invalid probabilities: {probabilities}") - if not math.isclose(sum(probabilities), 1.0, abs_tol=1e-5): - raise ValueError(f"model probabilities do not sum to one: {probabilities}") - best = 0 - for index, p in enumerate(probabilities): - if p > probabilities[best]: - best = index + """The Jev choice answer; ties go to the earliest option. `confidence` is the + normalized entropy `1 - H(p) / ln(n)`, as the LAYA worker reports it.""" + n = len(probabilities) + entropy = -sum(p * math.log(p) for p in probabilities if p > 0) return { "type": "choice", - "choice": question.keys[best], - "probabilities": dict(zip(question.keys, probabilities, strict=True)), - "confidence": confidence(probabilities), + "choice": question.keys[max(range(n), key=probabilities.__getitem__)], + "probabilities": dict(zip(question.keys, probabilities)), + "confidence": max(0.0, 1 - entropy / math.log(n)) if n > 1 else 1.0, } - - -def model_identity(revision: str = ADAPTER_REVISION) -> str: - return f"{ADAPTER_REPO}@{revision}:text" diff --git a/src/models/cua_s1/text/model.py b/src/models/cua_s1/text/model.py index 9724c42..ea0415a 100644 --- a/src/models/cua_s1/text/model.py +++ b/src/models/cua_s1/text/model.py @@ -1,129 +1,45 @@ -"""Load Qwen3.5-4B with the Cua-S1 `text` adapter and score one prompt. - -The calls mirror upstream `cua_s1.four_b.FourBModel` (text modality): the same -model class, an unmerged PEFT adapter, the chat template with its default -generation prompt, full logits, and a fp32 softmax over the letter logits at -the last position. Keeping them the same is what makes the worker's -probabilities bitwise identical to the reference in the same environment. - -Torch, Transformers and PEFT are imported only when a model is loaded, so the -adapter checks can run without them. +"""Qwen3.5-4B with the Cua-S1 `text` adapter, loaded and scored as upstream +`cua_s1.four_b.FourBModel` does: an unmerged PEFT adapter, the chat template with its +generation prompt, full logits, and a float32 softmax over the option letters at the +last position. That keeps the probabilities bitwise identical to the reference. """ from __future__ import annotations import json -import re -import time -from dataclasses import dataclass from pathlib import Path -from .contract import BASE_REPO, LETTERS, Question, build_messages - - -def text_adapter_dir(adapter_root: str | Path) -> Path: - """Return the `text` adapter directory under the adapter root. - - Accepts the repository root (`/text`) or the `text/` directory - itself, and refuses the `multimodal/` adapter: PEFT only warns about keys - it cannot place, so loading the wrong adapter would otherwise go unnoticed. - """ - root = Path(adapter_root) - path = root / "text" if (root / "text" / "adapter_config.json").exists() else root - config_file = path / "adapter_config.json" - if not config_file.exists(): - raise RuntimeError(f"no adapter_config.json under {root}") - config = json.loads(config_file.read_text()) - if config.get("base_model_name_or_path") != BASE_REPO: - raise RuntimeError(f"{config_file}: base model is not {BASE_REPO}") - if {"linear_fc1", "linear_fc2"} & set(config.get("target_modules") or []): - raise RuntimeError( - f"{config_file}: this is the multimodal adapter, not the text adapter" - ) - return path - - -def downloaded_revision(adapter_root: str | Path) -> str | None: - """The commit that `hf download --local-dir` recorded for the text adapter, if any. +import torch +from peft import PeftModel +from transformers import AutoModelForCausalLM, AutoTokenizer - `hf download` keeps its metadata under the repository root, so this also - looks one level up when `adapter_root` is the `text/` directory itself. - """ - root = Path(adapter_root) - places = [(root, "text/"), (root, "")] - if root.name == "text": - places.insert(0, (root.parent, "text/")) - for base, prefix in places: - cache = base / ".cache" / "huggingface" / "download" - try: - meta = (cache / f"{prefix}adapter_model.safetensors.metadata").read_text() - first = meta.splitlines()[0].strip() - except (OSError, IndexError): - continue - if re.fullmatch(r"[0-9a-f]{40}", first): - return first - return None - - -@dataclass -class Scored: - probabilities: list[float] - prompt_tokens: int +from .contract import LETTERS, Question, build_messages class TextModel: - def __init__( - self, base_model: str, adapter_root: str, device: str, dtype: str - ) -> None: - import torch - from peft import PeftModel - from transformers import AutoModelForCausalLM, AutoTokenizer - - self.device = device - self.dtype = dtype - started = time.perf_counter() - self.tokenizer = AutoTokenizer.from_pretrained(base_model) + def __init__(self, base: str, adapter: str, device: str, dtype: str) -> None: + # PEFT only warns about keys it cannot place: refuse the multimodal adapter. + config = json.loads((Path(adapter) / "adapter_config.json").read_text()) + if "linear_fc1" in config["target_modules"]: + raise ValueError( + f"{adapter} is the multimodal adapter; pass its text/ directory" + ) + self.tokenizer = AutoTokenizer.from_pretrained(base) model = AutoModelForCausalLM.from_pretrained( - base_model, dtype=getattr(torch, dtype), device_map=device + base, dtype=getattr(torch, dtype), device_map=device ) - model = PeftModel.from_pretrained(model, str(text_adapter_dir(adapter_root))) - model.eval() - self.model = model - self.load_seconds = time.perf_counter() - started - self.letter_ids = self._letter_ids() - - def _letter_ids(self) -> list[int]: - ids = [] - for letter in LETTERS: - tokens = self.tokenizer.encode(letter, add_special_tokens=False) - if len(tokens) != 1: - raise RuntimeError(f"letter {letter!r} is not a single token: {tokens}") - ids.append(tokens[0]) - return ids + self.model = PeftModel.from_pretrained(model, adapter).eval() + self.letter_ids = self.tokenizer.convert_tokens_to_ids(list(LETTERS)) def encode(self, state: str, question: Question): - """Tokenized prompt for one question, on CPU. - - The Qwen3.5 tokenizer adds no special tokens here (contract point 4); - the chat template already contains them. - """ - chat_text = self.tokenizer.apply_chat_template( + text = self.tokenizer.apply_chat_template( build_messages(state, question), tokenize=False, add_generation_prompt=True ) - return self.tokenizer(chat_text, return_tensors="pt") - - def score_encoded(self, inputs, n_options: int) -> Scored: - import torch - - with torch.no_grad(): - inputs = inputs.to(self.model.device) - out = self.model(**inputs) - final_logits = out.logits[0, -1, :] - letter_ids = self.letter_ids[:n_options] - option_logits = final_logits[ - torch.tensor(letter_ids, device=final_logits.device) - ] - probabilities = torch.softmax(option_logits.float(), dim=-1).tolist() - return Scored( - probabilities=probabilities, prompt_tokens=int(inputs["input_ids"].shape[1]) - ) + return self.tokenizer(text, return_tensors="pt") + + @torch.no_grad() + def score(self, inputs, n_options: int) -> list[float]: + logits = self.model(**inputs.to(self.model.device)).logits[0, -1] + return torch.softmax( + logits[self.letter_ids[:n_options]].float(), dim=-1 + ).tolist() diff --git a/tests/cua_s1/data/text_inputs.json b/tests/cua_s1/data/text_inputs.json deleted file mode 100644 index 1a56460..0000000 --- a/tests/cua_s1/data/text_inputs.json +++ /dev/null @@ -1,289 +0,0 @@ -{ - "fixture_positive": { - "model": "cua-s1-4b-0.2", - "state": "Visual-region-derived observation for capture \"capture-fixture-1\":\n\"submit-text\": text 'Submit' at (300,240,100,40) confidence=0.96 interactive=true", - "questions": { - "pick": { - "type": "choice", - "instructions": "Submit the verified form.", - "criteria": { - "submit-form": "Submit using the unique validated visual region.", - "reobserve": "Discard this decision set and obtain a fresh observation.", - "abstain": "Stop without acting if no supplied action is safe." - } - } - } - }, - "fixture_negative": { - "model": "cua-s1-4b-0.2", - "state": "Visual-region-derived observation for capture \"synthetic-negative-1\":\n\"save\": text 'Save' at (10,10,80,30) confidence=0.98 interactive=true", - "questions": { - "pick": { - "type": "choice", - "instructions": "Choose exactly one candidate by applying its condition to the supplied state. The host alone authorizes any selected action.", - "criteria": { - "region:save": "Select only if exactly one supplied region has id=save, kind=text, exact_text=\"Send\", confidence=0.98, and it is the sole exact Send match at or above 0.80.", - "reobserve": "Select only when no action candidate condition matches and no regions are supplied. Do not act; request one fresh bounded observation.", - "abstain": "Select only when no action candidate condition matches and one or more regions are supplied. Do not act; stop." - } - } - } - }, - "one_option": { - "model": "cua-s1-4b-0.2", - "state": "Dialog: 'Update installed.' Button: OK", - "questions": { - "pick": { - "type": "choice", - "instructions": "Close the dialog.", - "criteria": { - "ok": "Click OK" - } - } - } - }, - "two_options": { - "model": "cua-s1-4b-0.2", - "state": "Dialog: 'Delete 3 files permanently?' Buttons: Delete, Cancel", - "questions": { - "pick": { - "type": "choice", - "instructions": "Keep the files.", - "criteria": { - "delete": "Click Delete", - "cancel": "Click Cancel" - } - } - } - }, - "max_26_options": { - "model": "cua-s1-4b-0.2", - "state": "Toolbar of a document editor. Selected text: 'quarterly results'.\nbutton 'Undo' enabled=true\nbutton 'Redo' enabled=true\nbutton 'Cut' enabled=true\nbutton 'Copy' enabled=true\nbutton 'Paste' enabled=true\nbutton 'Bold' enabled=true\nbutton 'Italic' enabled=true\nbutton 'Underline' enabled=true\nbutton 'Strikethrough' enabled=true\nbutton 'Font color' enabled=true\nbutton 'Highlight' enabled=true\nbutton 'Align left' enabled=true\nbutton 'Center' enabled=true\nbutton 'Align right' enabled=true\nbutton 'Justify' enabled=true\nbutton 'Bullets' enabled=true\nbutton 'Numbering' enabled=true\nbutton 'Indent' enabled=true\nbutton 'Outdent' enabled=true\nbutton 'Insert link' enabled=true\nbutton 'Insert image' enabled=true\nbutton 'Insert table' enabled=true\nbutton 'Comment' enabled=true\nbutton 'Find' enabled=true\nbutton 'Replace' enabled=true\nbutton 'Print' enabled=true", - "questions": { - "pick": { - "type": "choice", - "instructions": "Make the selected text bold.", - "criteria": { - "undo": "Click the 'Undo' button", - "redo": "Click the 'Redo' button", - "cut": "Click the 'Cut' button", - "copy": "Click the 'Copy' button", - "paste": "Click the 'Paste' button", - "bold": "Click the 'Bold' button", - "italic": "Click the 'Italic' button", - "underline": "Click the 'Underline' button", - "strikethrough": "Click the 'Strikethrough' button", - "font-color": "Click the 'Font color' button", - "highlight": "Click the 'Highlight' button", - "align-left": "Click the 'Align left' button", - "center": "Click the 'Center' button", - "align-right": "Click the 'Align right' button", - "justify": "Click the 'Justify' button", - "bullets": "Click the 'Bullets' button", - "numbering": "Click the 'Numbering' button", - "indent": "Click the 'Indent' button", - "outdent": "Click the 'Outdent' button", - "insert-link": "Click the 'Insert link' button", - "insert-image": "Click the 'Insert image' button", - "insert-table": "Click the 'Insert table' button", - "comment": "Click the 'Comment' button", - "find": "Click the 'Find' button", - "replace": "Click the 'Replace' button", - "print": "Click the 'Print' button" - } - } - } - }, - "long_state": { - "model": "cua-s1-4b-0.2", - "state": "Orders table (web admin), 300 rows, sorted by order number.\nrow 0: cell 'Order #10000' | cell 'Customer 0' | cell '10.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 1: cell 'Order #10001' | cell 'Customer 1' | cell '17.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 2: cell 'Order #10002' | cell 'Customer 2' | cell '24.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 3: cell 'Order #10003' | cell 'Customer 3' | cell '31.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 4: cell 'Order #10004' | cell 'Customer 4' | cell '38.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 5: cell 'Order #10005' | cell 'Customer 5' | cell '45.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 6: cell 'Order #10006' | cell 'Customer 6' | cell '52.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 7: cell 'Order #10007' | cell 'Customer 7' | cell '59.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 8: cell 'Order #10008' | cell 'Customer 8' | cell '66.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 9: cell 'Order #10009' | cell 'Customer 9' | cell '73.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 10: cell 'Order #10010' | cell 'Customer 10' | cell '80.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 11: cell 'Order #10011' | cell 'Customer 11' | cell '87.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 12: cell 'Order #10012' | cell 'Customer 12' | cell '94.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 13: cell 'Order #10013' | cell 'Customer 13' | cell '11.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 14: cell 'Order #10014' | cell 'Customer 14' | cell '18.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 15: cell 'Order #10015' | cell 'Customer 15' | cell '25.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 16: cell 'Order #10016' | cell 'Customer 16' | cell '32.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 17: cell 'Order #10017' | cell 'Customer 17' | cell '39.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 18: cell 'Order #10018' | cell 'Customer 18' | cell '46.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 19: cell 'Order #10019' | cell 'Customer 19' | cell '53.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 20: cell 'Order #10020' | cell 'Customer 20' | cell '60.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 21: cell 'Order #10021' | cell 'Customer 21' | cell '67.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 22: cell 'Order #10022' | cell 'Customer 22' | cell '74.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 23: cell 'Order #10023' | cell 'Customer 23' | cell '81.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 24: cell 'Order #10024' | cell 'Customer 24' | cell '88.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 25: cell 'Order #10025' | cell 'Customer 25' | cell '95.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 26: cell 'Order #10026' | cell 'Customer 26' | cell '12.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 27: cell 'Order #10027' | cell 'Customer 27' | cell '19.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 28: cell 'Order #10028' | cell 'Customer 28' | cell '26.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 29: cell 'Order #10029' | cell 'Customer 29' | cell '33.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 30: cell 'Order #10030' | cell 'Customer 30' | cell '40.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 31: cell 'Order #10031' | cell 'Customer 31' | cell '47.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 32: cell 'Order #10032' | cell 'Customer 32' | cell '54.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 33: cell 'Order #10033' | cell 'Customer 33' | cell '61.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 34: cell 'Order #10034' | cell 'Customer 34' | cell '68.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 35: cell 'Order #10035' | cell 'Customer 35' | cell '75.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 36: cell 'Order #10036' | cell 'Customer 36' | cell '82.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 37: cell 'Order #10037' | cell 'Customer 0' | cell '89.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 38: cell 'Order #10038' | cell 'Customer 1' | cell '96.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 39: cell 'Order #10039' | cell 'Customer 2' | cell '13.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 40: cell 'Order #10040' | cell 'Customer 3' | cell '20.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 41: cell 'Order #10041' | cell 'Customer 4' | cell '27.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 42: cell 'Order #10042' | cell 'Customer 5' | cell '34.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 43: cell 'Order #10043' | cell 'Customer 6' | cell '41.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 44: cell 'Order #10044' | cell 'Customer 7' | cell '48.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 45: cell 'Order #10045' | cell 'Customer 8' | cell '55.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 46: cell 'Order #10046' | cell 'Customer 9' | cell '62.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 47: cell 'Order #10047' | cell 'Customer 10' | cell '69.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 48: cell 'Order #10048' | cell 'Customer 11' | cell '76.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 49: cell 'Order #10049' | cell 'Customer 12' | cell '83.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 50: cell 'Order #10050' | cell 'Customer 13' | cell '90.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 51: cell 'Order #10051' | cell 'Customer 14' | cell '97.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 52: cell 'Order #10052' | cell 'Customer 15' | cell '14.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 53: cell 'Order #10053' | cell 'Customer 16' | cell '21.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 54: cell 'Order #10054' | cell 'Customer 17' | cell '28.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 55: cell 'Order #10055' | cell 'Customer 18' | cell '35.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 56: cell 'Order #10056' | cell 'Customer 19' | cell '42.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 57: cell 'Order #10057' | cell 'Customer 20' | cell '49.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 58: cell 'Order #10058' | cell 'Customer 21' | cell '56.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 59: cell 'Order #10059' | cell 'Customer 22' | cell '63.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 60: cell 'Order #10060' | cell 'Customer 23' | cell '70.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 61: cell 'Order #10061' | cell 'Customer 24' | cell '77.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 62: cell 'Order #10062' | cell 'Customer 25' | cell '84.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 63: cell 'Order #10063' | cell 'Customer 26' | cell '91.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 64: cell 'Order #10064' | cell 'Customer 27' | cell '98.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 65: cell 'Order #10065' | cell 'Customer 28' | cell '15.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 66: cell 'Order #10066' | cell 'Customer 29' | cell '22.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 67: cell 'Order #10067' | cell 'Customer 30' | cell '29.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 68: cell 'Order #10068' | cell 'Customer 31' | cell '36.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 69: cell 'Order #10069' | cell 'Customer 32' | cell '43.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 70: cell 'Order #10070' | cell 'Customer 33' | cell '50.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 71: cell 'Order #10071' | cell 'Customer 34' | cell '57.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 72: cell 'Order #10072' | cell 'Customer 35' | cell '64.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 73: cell 'Order #10073' | cell 'Customer 36' | cell '71.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 74: cell 'Order #10074' | cell 'Customer 0' | cell '78.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 75: cell 'Order #10075' | cell 'Customer 1' | cell '85.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 76: cell 'Order #10076' | cell 'Customer 2' | cell '92.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 77: cell 'Order #10077' | cell 'Customer 3' | cell '99.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 78: cell 'Order #10078' | cell 'Customer 4' | cell '16.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 79: cell 'Order #10079' | cell 'Customer 5' | cell '23.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 80: cell 'Order #10080' | cell 'Customer 6' | cell '30.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 81: cell 'Order #10081' | cell 'Customer 7' | cell '37.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 82: cell 'Order #10082' | cell 'Customer 8' | cell '44.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 83: cell 'Order #10083' | cell 'Customer 9' | cell '51.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 84: cell 'Order #10084' | cell 'Customer 10' | cell '58.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 85: cell 'Order #10085' | cell 'Customer 11' | cell '65.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 86: cell 'Order #10086' | cell 'Customer 12' | cell '72.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 87: cell 'Order #10087' | cell 'Customer 13' | cell '79.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 88: cell 'Order #10088' | cell 'Customer 14' | cell '86.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 89: cell 'Order #10089' | cell 'Customer 15' | cell '93.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 90: cell 'Order #10090' | cell 'Customer 16' | cell '10.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 91: cell 'Order #10091' | cell 'Customer 17' | cell '17.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 92: cell 'Order #10092' | cell 'Customer 18' | cell '24.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 93: cell 'Order #10093' | cell 'Customer 19' | cell '31.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 94: cell 'Order #10094' | cell 'Customer 20' | cell '38.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 95: cell 'Order #10095' | cell 'Customer 21' | cell '45.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 96: cell 'Order #10096' | cell 'Customer 22' | cell '52.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 97: cell 'Order #10097' | cell 'Customer 23' | cell '59.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 98: cell 'Order #10098' | cell 'Customer 24' | cell '66.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 99: cell 'Order #10099' | cell 'Customer 25' | cell '73.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 100: cell 'Order #10100' | cell 'Customer 26' | cell '80.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 101: cell 'Order #10101' | cell 'Customer 27' | cell '87.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 102: cell 'Order #10102' | cell 'Customer 28' | cell '94.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 103: cell 'Order #10103' | cell 'Customer 29' | cell '11.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 104: cell 'Order #10104' | cell 'Customer 30' | cell '18.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 105: cell 'Order #10105' | cell 'Customer 31' | cell '25.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 106: cell 'Order #10106' | cell 'Customer 32' | cell '32.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 107: cell 'Order #10107' | cell 'Customer 33' | cell '39.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 108: cell 'Order #10108' | cell 'Customer 34' | cell '46.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 109: cell 'Order #10109' | cell 'Customer 35' | cell '53.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 110: cell 'Order #10110' | cell 'Customer 36' | cell '60.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 111: cell 'Order #10111' | cell 'Customer 0' | cell '67.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 112: cell 'Order #10112' | cell 'Customer 1' | cell '74.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 113: cell 'Order #10113' | cell 'Customer 2' | cell '81.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 114: cell 'Order #10114' | cell 'Customer 3' | cell '88.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 115: cell 'Order #10115' | cell 'Customer 4' | cell '95.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 116: cell 'Order #10116' | cell 'Customer 5' | cell '12.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 117: cell 'Order #10117' | cell 'Customer 6' | cell '19.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 118: cell 'Order #10118' | cell 'Customer 7' | cell '26.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 119: cell 'Order #10119' | cell 'Customer 8' | cell '33.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 120: cell 'Order #10120' | cell 'Customer 9' | cell '40.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 121: cell 'Order #10121' | cell 'Customer 10' | cell '47.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 122: cell 'Order #10122' | cell 'Customer 11' | cell '54.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 123: cell 'Order #10123' | cell 'Customer 12' | cell '61.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 124: cell 'Order #10124' | cell 'Customer 13' | cell '68.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 125: cell 'Order #10125' | cell 'Customer 14' | cell '75.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 126: cell 'Order #10126' | cell 'Customer 15' | cell '82.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 127: cell 'Order #10127' | cell 'Customer 16' | cell '89.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 128: cell 'Order #10128' | cell 'Customer 17' | cell '96.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 129: cell 'Order #10129' | cell 'Customer 18' | cell '13.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 130: cell 'Order #10130' | cell 'Customer 19' | cell '20.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 131: cell 'Order #10131' | cell 'Customer 20' | cell '27.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 132: cell 'Order #10132' | cell 'Customer 21' | cell '34.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 133: cell 'Order #10133' | cell 'Customer 22' | cell '41.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 134: cell 'Order #10134' | cell 'Customer 23' | cell '48.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 135: cell 'Order #10135' | cell 'Customer 24' | cell '55.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 136: cell 'Order #10136' | cell 'Customer 25' | cell '62.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 137: cell 'Order #10137' | cell 'Customer 26' | cell '69.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 138: cell 'Order #10138' | cell 'Customer 27' | cell '76.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 139: cell 'Order #10139' | cell 'Customer 28' | cell '83.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 140: cell 'Order #10140' | cell 'Customer 29' | cell '90.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 141: cell 'Order #10141' | cell 'Customer 30' | cell '97.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 142: cell 'Order #10142' | cell 'Customer 31' | cell '14.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 143: cell 'Order #10143' | cell 'Customer 32' | cell '21.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 144: cell 'Order #10144' | cell 'Customer 33' | cell '28.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 145: cell 'Order #10145' | cell 'Customer 34' | cell '35.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 146: cell 'Order #10146' | cell 'Customer 35' | cell '42.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 147: cell 'Order #10147' | cell 'Customer 36' | cell '49.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 148: cell 'Order #10148' | cell 'Customer 0' | cell '56.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 149: cell 'Order #10149' | cell 'Customer 1' | cell '63.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 150: cell 'Order #10150' | cell 'Customer 2' | cell '70.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 151: cell 'Order #10151' | cell 'Customer 3' | cell '77.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 152: cell 'Order #10152' | cell 'Customer 4' | cell '84.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 153: cell 'Order #10153' | cell 'Customer 5' | cell '91.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 154: cell 'Order #10154' | cell 'Customer 6' | cell '98.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 155: cell 'Order #10155' | cell 'Customer 7' | cell '15.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 156: cell 'Order #10156' | cell 'Customer 8' | cell '22.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 157: cell 'Order #10157' | cell 'Customer 9' | cell '29.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 158: cell 'Order #10158' | cell 'Customer 10' | cell '36.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 159: cell 'Order #10159' | cell 'Customer 11' | cell '43.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 160: cell 'Order #10160' | cell 'Customer 12' | cell '50.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 161: cell 'Order #10161' | cell 'Customer 13' | cell '57.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 162: cell 'Order #10162' | cell 'Customer 14' | cell '64.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 163: cell 'Order #10163' | cell 'Customer 15' | cell '71.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 164: cell 'Order #10164' | cell 'Customer 16' | cell '78.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 165: cell 'Order #10165' | cell 'Customer 17' | cell '85.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 166: cell 'Order #10166' | cell 'Customer 18' | cell '92.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 167: cell 'Order #10167' | cell 'Customer 19' | cell '99.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 168: cell 'Order #10168' | cell 'Customer 20' | cell '16.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 169: cell 'Order #10169' | cell 'Customer 21' | cell '23.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 170: cell 'Order #10170' | cell 'Customer 22' | cell '30.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 171: cell 'Order #10171' | cell 'Customer 23' | cell '37.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 172: cell 'Order #10172' | cell 'Customer 24' | cell '44.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 173: cell 'Order #10173' | cell 'Customer 25' | cell '51.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 174: cell 'Order #10174' | cell 'Customer 26' | cell '58.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 175: cell 'Order #10175' | cell 'Customer 27' | cell '65.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 176: cell 'Order #10176' | cell 'Customer 28' | cell '72.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 177: cell 'Order #10177' | cell 'Customer 29' | cell '79.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 178: cell 'Order #10178' | cell 'Customer 30' | cell '86.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 179: cell 'Order #10179' | cell 'Customer 31' | cell '93.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 180: cell 'Order #10180' | cell 'Customer 32' | cell '10.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 181: cell 'Order #10181' | cell 'Customer 33' | cell '17.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 182: cell 'Order #10182' | cell 'Customer 34' | cell '24.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 183: cell 'Order #10183' | cell 'Customer 35' | cell '31.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 184: cell 'Order #10184' | cell 'Customer 36' | cell '38.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 185: cell 'Order #10185' | cell 'Customer 0' | cell '45.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 186: cell 'Order #10186' | cell 'Customer 1' | cell '52.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 187: cell 'Order #10187' | cell 'Customer 2' | cell '59.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 188: cell 'Order #10188' | cell 'Customer 3' | cell '66.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 189: cell 'Order #10189' | cell 'Customer 4' | cell '73.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 190: cell 'Order #10190' | cell 'Customer 5' | cell '80.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 191: cell 'Order #10191' | cell 'Customer 6' | cell '87.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 192: cell 'Order #10192' | cell 'Customer 7' | cell '94.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 193: cell 'Order #10193' | cell 'Customer 8' | cell '11.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 194: cell 'Order #10194' | cell 'Customer 9' | cell '18.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 195: cell 'Order #10195' | cell 'Customer 10' | cell '25.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 196: cell 'Order #10196' | cell 'Customer 11' | cell '32.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 197: cell 'Order #10197' | cell 'Customer 12' | cell '39.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 198: cell 'Order #10198' | cell 'Customer 13' | cell '46.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 199: cell 'Order #10199' | cell 'Customer 14' | cell '53.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 200: cell 'Order #10200' | cell 'Customer 15' | cell '60.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 201: cell 'Order #10201' | cell 'Customer 16' | cell '67.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 202: cell 'Order #10202' | cell 'Customer 17' | cell '74.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 203: cell 'Order #10203' | cell 'Customer 18' | cell '81.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 204: cell 'Order #10204' | cell 'Customer 19' | cell '88.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 205: cell 'Order #10205' | cell 'Customer 20' | cell '95.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 206: cell 'Order #10206' | cell 'Customer 21' | cell '12.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 207: cell 'Order #10207' | cell 'Customer 22' | cell '19.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 208: cell 'Order #10208' | cell 'Customer 23' | cell '26.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 209: cell 'Order #10209' | cell 'Customer 24' | cell '33.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 210: cell 'Order #10210' | cell 'Customer 25' | cell '40.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 211: cell 'Order #10211' | cell 'Customer 26' | cell '47.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 212: cell 'Order #10212' | cell 'Customer 27' | cell '54.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 213: cell 'Order #10213' | cell 'Customer 28' | cell '61.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 214: cell 'Order #10214' | cell 'Customer 29' | cell '68.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 215: cell 'Order #10215' | cell 'Customer 30' | cell '75.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 216: cell 'Order #10216' | cell 'Customer 31' | cell '82.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 217 note: customer reported a duplicate charge on this order\nrow 217: cell 'Order #10217' | cell 'Customer 32' | cell '89.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 218: cell 'Order #10218' | cell 'Customer 33' | cell '96.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 219: cell 'Order #10219' | cell 'Customer 34' | cell '13.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 220: cell 'Order #10220' | cell 'Customer 35' | cell '20.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 221: cell 'Order #10221' | cell 'Customer 36' | cell '27.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 222: cell 'Order #10222' | cell 'Customer 0' | cell '34.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 223: cell 'Order #10223' | cell 'Customer 1' | cell '41.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 224: cell 'Order #10224' | cell 'Customer 2' | cell '48.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 225: cell 'Order #10225' | cell 'Customer 3' | cell '55.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 226: cell 'Order #10226' | cell 'Customer 4' | cell '62.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 227: cell 'Order #10227' | cell 'Customer 5' | cell '69.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 228: cell 'Order #10228' | cell 'Customer 6' | cell '76.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 229: cell 'Order #10229' | cell 'Customer 7' | cell '83.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 230: cell 'Order #10230' | cell 'Customer 8' | cell '90.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 231: cell 'Order #10231' | cell 'Customer 9' | cell '97.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 232: cell 'Order #10232' | cell 'Customer 10' | cell '14.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 233: cell 'Order #10233' | cell 'Customer 11' | cell '21.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 234: cell 'Order #10234' | cell 'Customer 12' | cell '28.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 235: cell 'Order #10235' | cell 'Customer 13' | cell '35.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 236: cell 'Order #10236' | cell 'Customer 14' | cell '42.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 237: cell 'Order #10237' | cell 'Customer 15' | cell '49.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 238: cell 'Order #10238' | cell 'Customer 16' | cell '56.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 239: cell 'Order #10239' | cell 'Customer 17' | cell '63.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 240: cell 'Order #10240' | cell 'Customer 18' | cell '70.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 241: cell 'Order #10241' | cell 'Customer 19' | cell '77.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 242: cell 'Order #10242' | cell 'Customer 20' | cell '84.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 243: cell 'Order #10243' | cell 'Customer 21' | cell '91.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 244: cell 'Order #10244' | cell 'Customer 22' | cell '98.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 245: cell 'Order #10245' | cell 'Customer 23' | cell '15.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 246: cell 'Order #10246' | cell 'Customer 24' | cell '22.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 247: cell 'Order #10247' | cell 'Customer 25' | cell '29.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 248: cell 'Order #10248' | cell 'Customer 26' | cell '36.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 249: cell 'Order #10249' | cell 'Customer 27' | cell '43.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 250: cell 'Order #10250' | cell 'Customer 28' | cell '50.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 251: cell 'Order #10251' | cell 'Customer 29' | cell '57.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 252: cell 'Order #10252' | cell 'Customer 30' | cell '64.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 253: cell 'Order #10253' | cell 'Customer 31' | cell '71.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 254: cell 'Order #10254' | cell 'Customer 32' | cell '78.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 255: cell 'Order #10255' | cell 'Customer 33' | cell '85.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 256: cell 'Order #10256' | cell 'Customer 34' | cell '92.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 257: cell 'Order #10257' | cell 'Customer 35' | cell '99.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 258: cell 'Order #10258' | cell 'Customer 36' | cell '16.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 259: cell 'Order #10259' | cell 'Customer 0' | cell '23.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 260: cell 'Order #10260' | cell 'Customer 1' | cell '30.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 261: cell 'Order #10261' | cell 'Customer 2' | cell '37.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 262: cell 'Order #10262' | cell 'Customer 3' | cell '44.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 263: cell 'Order #10263' | cell 'Customer 4' | cell '51.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 264: cell 'Order #10264' | cell 'Customer 5' | cell '58.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 265: cell 'Order #10265' | cell 'Customer 6' | cell '65.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 266: cell 'Order #10266' | cell 'Customer 7' | cell '72.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 267: cell 'Order #10267' | cell 'Customer 8' | cell '79.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 268: cell 'Order #10268' | cell 'Customer 9' | cell '86.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 269: cell 'Order #10269' | cell 'Customer 10' | cell '93.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 270: cell 'Order #10270' | cell 'Customer 11' | cell '10.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 271: cell 'Order #10271' | cell 'Customer 12' | cell '17.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 272: cell 'Order #10272' | cell 'Customer 13' | cell '24.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 273: cell 'Order #10273' | cell 'Customer 14' | cell '31.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 274: cell 'Order #10274' | cell 'Customer 15' | cell '38.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 275: cell 'Order #10275' | cell 'Customer 16' | cell '45.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 276: cell 'Order #10276' | cell 'Customer 17' | cell '52.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 277: cell 'Order #10277' | cell 'Customer 18' | cell '59.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 278: cell 'Order #10278' | cell 'Customer 19' | cell '66.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 279: cell 'Order #10279' | cell 'Customer 20' | cell '73.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 280: cell 'Order #10280' | cell 'Customer 21' | cell '80.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 281: cell 'Order #10281' | cell 'Customer 22' | cell '87.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 282: cell 'Order #10282' | cell 'Customer 23' | cell '94.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 283: cell 'Order #10283' | cell 'Customer 24' | cell '11.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 284: cell 'Order #10284' | cell 'Customer 25' | cell '18.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 285: cell 'Order #10285' | cell 'Customer 26' | cell '25.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 286: cell 'Order #10286' | cell 'Customer 27' | cell '32.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 287: cell 'Order #10287' | cell 'Customer 28' | cell '39.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 288: cell 'Order #10288' | cell 'Customer 29' | cell '46.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 289: cell 'Order #10289' | cell 'Customer 30' | cell '53.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 290: cell 'Order #10290' | cell 'Customer 31' | cell '60.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 291: cell 'Order #10291' | cell 'Customer 32' | cell '67.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 292: cell 'Order #10292' | cell 'Customer 33' | cell '74.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 293: cell 'Order #10293' | cell 'Customer 34' | cell '81.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 294: cell 'Order #10294' | cell 'Customer 35' | cell '88.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 295: cell 'Order #10295' | cell 'Customer 36' | cell '95.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 296: cell 'Order #10296' | cell 'Customer 0' | cell '12.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'\nrow 297: cell 'Order #10297' | cell 'Customer 1' | cell '19.00 USD' | cell 'Paid' | button 'Open' | button 'Refund'\nrow 298: cell 'Order #10298' | cell 'Customer 2' | cell '26.00 USD' | cell 'Pending' | button 'Open' | button 'Refund'\nrow 299: cell 'Order #10299' | cell 'Customer 3' | cell '33.00 USD' | cell 'Refunded' | button 'Open' | button 'Refund'", - "questions": { - "pick": { - "type": "choice", - "instructions": "Refund the order with the reported duplicate charge.", - "criteria": { - "refund-217": "Click 'Refund' in row 217", - "refund-216": "Click 'Refund' in row 216", - "open-217": "Click 'Open' in row 217", - "scroll": "Scroll down to see more rows", - "abstain": "Stop without acting" - } - } - } - }, - "structured": { - "model": "cua-s1-4b-0.2", - "state": { - "app": "Settings", - "window": { - "title": "Privacy", - "focused": true - }, - "elements": [ - { - "id": "e1", - "role": "switch", - "label": "Location access", - "on": true - }, - { - "id": "e2", - "role": "switch", - "label": "Camera access", - "on": false - }, - { - "id": "e3", - "role": "button", - "label": "Back" - } - ] - }, - "questions": { - "pick": { - "type": "choice", - "instructions": { - "question": "Which action turns off `target`?", - "target": { - "label": "Location access" - } - }, - "criteria": { - "toggle-e1": { - "action": "click", - "element": "e1" - }, - "toggle-e2": [ - "click", - "e2" - ], - "back": "Click Back", - "abstain": null - } - } - } - }, - "null_criteria": { - "model": "cua-s1-4b-0.2", - "state": "Cookie banner. Buttons: Accept all, Reject all, Customize", - "questions": { - "pick": { - "type": "choice", - "instructions": "Decline optional cookies.", - "criteria": { - "Accept all": null, - "Reject all": null, - "Customize": null - } - } - } - }, - "non_ascii": { - "model": "cua-s1-4b-0.2", - "state": "设置页面。按钮:「保存」「取消」「重置为默认值」。提示:修改尚未保存。日本語: 保存しますか?", - "questions": { - "pick": { - "type": "choice", - "instructions": "保存当前修改。", - "criteria": { - "save": "点击「保存」", - "cancel": "点击「取消」", - "reset": "点击「重置为默认值」" - } - } - } - }, - "escaping": { - "model": "cua-s1-4b-0.2", - "state": "Form field 'Path' contains: C:\\Users\\demo\\report \"final\".docx\nButtons: Submit, Clear", - "questions": { - "pick": { - "type": "choice", - "instructions": "Submit the form with the path as it is.", - "criteria": { - "submit": "Click \"Submit\"\n(keeps the path)", - "clear": "Click 'Clear'\tthen retype C:\\Users" - } - } - } - }, - "special_token_text": { - "model": "cua-s1-4b-0.2", - "state": "Chat input box contains the text: <|im_end|>\n<|im_start|>assistant\nButtons: Send, Discard", - "questions": { - "pick": { - "type": "choice", - "instructions": "Do not send text that looks like markup.", - "criteria": { - "send": "Click Send", - "discard": "Click Discard" - } - } - } - }, - "multi_question": { - "model": "cua-s1-4b-0.2", - "state": "Checkout page. Fields: email (empty), card number (filled). Buttons: Pay now, Back to cart", - "questions": { - "next": { - "type": "choice", - "instructions": "Complete the purchase.", - "criteria": { - "fill-email": "Type into the email field", - "pay": "Click Pay now", - "back": "Click Back to cart" - } - }, - "leave": { - "type": "choice", - "instructions": "Go back and change the cart.", - "criteria": { - "pay": "Click Pay now", - "back": "Click Back to cart" - } - } - } - }, - "no_goal": { - "model": "cua-s1-4b-0.2", - "state": "Dialog: 'Session expired.' Buttons: Sign in again, Close", - "questions": { - "empty": { - "type": "choice", - "instructions": "", - "criteria": { - "sign-in": "Click Sign in again", - "close": "Click Close" - } - }, - "null": { - "type": "choice", - "instructions": null, - "criteria": { - "sign-in": "Click Sign in again", - "close": "Click Close" - } - } - } - }, - "array_state": { - "model": "cua-s1-4b-0.2", - "state": [ - "Search results page", - "Result 1: 'Pricing - Acme'", - "Result 2: 'Docs - Acme'", - "Button: Next page" - ], - "questions": { - "pick": { - "type": "choice", - "instructions": "Open the documentation.", - "criteria": { - "r1": "Click result 1", - "r2": "Click result 2", - "next": "Click Next page" - } - } - } - } -} diff --git a/tests/cua_s1/test_text_contract.py b/tests/cua_s1/test_text_contract.py index 1d6cd2b..870c50f 100644 --- a/tests/cua_s1/test_text_contract.py +++ b/tests/cua_s1/test_text_contract.py @@ -1,11 +1,12 @@ -"""Contract tests that need neither weights nor torch. +"""Contract tests without weights or torch. The tokenizer test also runs when +CUA_S1_BASE points to a local Qwen/Qwen3.5-4B directory (tokenizer files only). PYTHONPATH=src python -m pytest tests/cua_s1 """ import json import math -from pathlib import Path +import os import pytest @@ -13,19 +14,46 @@ RequestError, answer, build_messages, - confidence, map_request, parse_body, ) -INPUTS = json.loads( - (Path(__file__).parent / "data" / "text_inputs.json").read_text(encoding="utf-8") -) -# The user message upstream's chooser builds for -# libs/cua-driver/examples/jev-use/fixtures/jev-choice-request-v1.json at the -# pinned revision (FourBModel text modality). -FIXTURE_POSITIVE_USER = ( +def body(state="Screen", **question): + q = { + "type": "choice", + "instructions": "Pick one.", + "criteria": {"a": "A", "b": "B"}, + } + q.update(question) + return {"model": "cua-s1-4b-0.2", "state": state, "questions": {"q": q}} + + +def mapped(request): + return map_request(parse_body(json.dumps(request).encode())) + + +def reject(request, status=422): + raw = request if isinstance(request, bytes) else json.dumps(request).encode() + with pytest.raises(RequestError) as info: + map_request(parse_body(raw)) + assert info.value.status == status + return str(info.value) + + +# upstream's libs/cua-driver/examples/jev-use/fixtures/jev-choice-request-v1.json, +# with the chooser's rendered region as `state`, and the user message upstream builds for it +FIXTURE = body( + 'Visual-region-derived observation for capture "capture-fixture-1":\n' + "\"submit-text\": text 'Submit' at (300,240,100,40) confidence=0.96 interactive=true", + instructions="Submit the verified form.", + criteria={ + "submit-form": "Submit using the unique validated visual region.", + "reobserve": "Discard this decision set and obtain a fresh observation.", + "abstain": "Stop without acting if no supplied action is safe.", + }, +) +FIXTURE_USER = ( "Goal: Submit the verified form.\n\n" "App: Cua Driver\nTask family: closed-candidate decision\n\n" "Accessibility tree:\n" @@ -39,178 +67,117 @@ ) -def mapped(name): - return map_request(parse_body(json.dumps(INPUTS[name]).encode())) - - -def reject(body, status=422): - raw = body if isinstance(body, bytes) else json.dumps(body).encode() - with pytest.raises(RequestError) as info: - map_request(parse_body(raw)) - assert info.value.status == status - return str(info.value) - - -def base(**question): - q = { - "type": "choice", - "instructions": "Pick one.", - "criteria": {"a": "A", "b": "B"}, - } - q.update(question) - return {"model": "cua-s1-4b-0.2", "state": "Screen", "questions": {"q": q}} - - def test_fixture_prompt_matches_upstream(): - request = mapped("fixture_positive") - messages = build_messages(request.state, request.questions[0]) - assert messages[1] == {"role": "user", "content": FIXTURE_POSITIVE_USER} - assert messages[0]["role"] == "system" - assert messages[0]["content"].startswith( + state, (question,) = mapped(FIXTURE) + system, user = build_messages(state, question) + assert user == {"role": "user", "content": FIXTURE_USER} + assert system["content"].startswith( "You are a one-pass computer-use decision model." ) -def test_every_input_maps(): - for name in INPUTS: - request = mapped(name) - assert request.questions - for question in request.questions: - assert 1 <= len(question.keys) <= 26 - - -def test_goal_line_left_out_when_empty_or_null(): - request = mapped("no_goal") - for question in request.questions: - user = build_messages(request.state, question)[1]["content"] - assert user.startswith("App: Cua Driver\n") - +@pytest.mark.skipif(not os.environ.get("CUA_S1_BASE"), reason="set CUA_S1_BASE to run") +def test_tokenizer(): + from transformers import AutoTokenizer -def test_structured_values_and_null_label(): - request = mapped("structured") - state = INPUTS["structured"]["state"] - assert request.state == json.dumps(state, ensure_ascii=False) - question = request.questions[0] - assert question.goal.startswith('{"question": "Which action turns off `target`?"') - assert question.labels[0] == '{\\"action\\": \\"click\\", \\"element\\": \\"e1\\"}' - assert question.labels[1] == '[\\"click\\", \\"e2\\"]' - assert question.labels[3] == "abstain" + tokenizer = AutoTokenizer.from_pretrained(os.environ["CUA_S1_BASE"]) + assert tokenizer.convert_tokens_to_ids(list("ABCDEFGHIJKLMNOPQRSTUVWXYZ")) == list( + range(32, 58) + ) + state, (question,) = mapped(FIXTURE) + text = tokenizer.apply_chat_template( + build_messages(state, question), tokenize=False, add_generation_prompt=True + ) + assert text.endswith("<|im_start|>assistant\n\n") + ids = tokenizer(text)["input_ids"] + assert len(ids) == 218 + assert ids == tokenizer(text, add_special_tokens=False)["input_ids"] -def test_label_escaping_matches_chooser(): - question = mapped("escaping").questions[0] - assert question.labels[0] == 'Click \\"Submit\\"\\n(keeps the path)' - assert question.labels[1] == "Click 'Clear'\\tthen retype C:\\\\Users" - assert mapped("non_ascii").questions[0].labels[0] == "点击「保存」" +def test_goal_left_out_when_empty_or_null(): + for goal in ["", None]: + state, (question,) = mapped(body(instructions=goal)) + assert build_messages(state, question)[1]["content"].startswith( + "App: Cua Driver\n" + ) -def test_score_or_noul_rejects_the_whole_request(): - body = base() - body["questions"]["s"] = { - "type": "score", - "instructions": "Rate it.", - "criteria": ["low", "high"], +def test_structured_values_escaping_and_null_label(): + tree = { + "app": "Settings", + "elements": [{"id": "e1", "label": "Location", "on": True}], } - assert "'score' is not supported" in reject(body) - body = base() - body["questions"]["n"] = {"type": "noul", "instructions": "Is it red?"} - assert "'noul' is not supported" in reject(body) - - -def test_option_count_limits(): - assert "at least one option" in reject(base(criteria={})) - many = {f"o{i}": f"Option {i}" for i in range(27)} - assert "27 options" in reject(base(criteria=many)) - assert ( - len( - map_request(base(criteria={f"o{i}": "x" for i in range(26)})) - .questions[0] - .keys + state, (question,) = mapped( + body( + tree, + instructions={"question": "Which one?"}, + criteria={ + "e1": {"action": "click", "element": "e1"}, + "e2": ["click", "e2"], + "quote": 'Click "Submit"\n(tab\there) C:\\Users', + "save": "点击「保存」", + "abstain": None, + }, ) - == 26 + ) + assert state == json.dumps(tree, ensure_ascii=False) + assert question.goal == '{"question": "Which one?"}' + assert question.labels == ( + '{\\"action\\": \\"click\\", \\"element\\": \\"e1\\"}', + '[\\"click\\", \\"e2\\"]', + 'Click \\"Submit\\"\\n(tab\\there) C:\\\\Users', + "点击「保存」", + "abstain", ) -def test_duplicate_keys_anywhere(): - raw = ( - b'{"model": "cua-s1-4b-0.2", "state": "S", "questions": {"q": {"type": "choice",' - b' "instructions": "I", "criteria": {"a": "A", "a": "B"}}}}' - ) - assert "duplicate key 'a'" in reject(raw, status=400) - raw = ( - b'{"model": "cua-s1-4b-0.2", "state": {"x": 1, "x": 2}, "questions": {"q": {"type":' - b' "choice", "instructions": "I", "criteria": {"a": "A"}}}}' +def test_request_errors(): + assert "not supported" in reject(body(type="score", criteria=["low", "high"])) + assert "not supported" in reject(body(type="noul")) + assert "unknown type" in reject(body(type="rank")) + assert "1 to 26 options" in reject(body(criteria={})) + assert "1 to 26 options" in reject(body(criteria={f"o{i}": "x" for i in range(27)})) + assert ( + len(mapped(body(criteria={f"o{i}": "x" for i in range(26)}))[1][0].keys) == 26 ) - assert "duplicate key 'x'" in reject(raw, status=400) + no_instructions = body() + del no_instructions["questions"]["q"]["instructions"] + assert "'instructions' is required" in reject(no_instructions) + for value in [1, 2.5, True]: + reject(body(criteria={"a": value})) + for state in ["", {}, [], None, 3, True]: + reject(body(state)) + assert "'model'" in reject({**body(), "model": "english"}) + many = body() + many["questions"] = {f"q{i}": many["questions"]["q"] for i in range(65)} + reject(many, status=413) @pytest.mark.parametrize( "raw", [ + b'{"model": "cua-s1-4b-0.2", "state": {"x": 1, "x": 2}}', b'{"model": "cua-s1-4b-0.2", "state": NaN}', b'{"model": "cua-s1-4b-0.2", "state": {"x": 1e400}}', b'{"model": "cua-s1-4b-0.2", "state": {"x": ' + b"9" * 5000 + b"}}", b'{"model": "cua-s1-4b-0.2", "state": "\\ud800"}', b"[" * 100000 + b"]" * 100000, b"\xff\xfe", - '{"model": "cua-s1-4b-0.2", "state": "S"}'.encode("utf-16"), - b"\xef\xbb\xbf" + b'{"model": "cua-s1-4b-0.2", "state": "S"}', + b"\xef\xbb\xbf{}", + b"not json", + b"[1, 2]", ], ) def test_malformed_bodies_are_400(raw): reject(raw, status=400) -def test_question_shape_errors(): - body = base() - body["questions"]["q"] = "not an object" - assert "must be an object" in reject(body) - assert "'criteria' must be an object" in reject(base(criteria=["a", "b"])) - body = base() - del body["questions"]["q"]["instructions"] - assert "'instructions' is required" in reject(body) - assert "unknown type 'rank'" in reject(base(type="rank")) - body = base() - body["questions"] = {f"q{i}": body["questions"]["q"] for i in range(3)} - with pytest.raises(RequestError) as info: - map_request(body, max_questions=2) - assert info.value.status == 413 - - -@pytest.mark.parametrize("value", [1, 2.5, True, False]) -def test_number_or_boolean_criteria_value(value): - assert "must be a string, an object or an array" in reject( - base(criteria={"a": value, "b": "B"}) - ) - - -@pytest.mark.parametrize("state", ["", {}, [], None, 3, True]) -def test_bad_state(state): - body = base() - body["state"] = state - reject(body) - - -def test_model_name_and_body_shape(): - body = base() - body["model"] = "english" - assert "'model' must be" in reject(body) - reject(b"not json", status=400) - reject(b"[1, 2]", status=400) - - -def test_confidence_is_normalized_entropy(): - assert confidence([1.0]) == 1.0 - assert confidence([0.5, 0.5]) == pytest.approx(0.0, abs=1e-12) - p = [0.88, 0.12, 0.0] - h = -(0.88 * math.log(0.88) + 0.12 * math.log(0.12)) - assert confidence(p) == pytest.approx(1 - h / math.log(3)) - - -def test_answer_shape_and_ties(): - question = mapped("two_options").questions[0] - result = answer(question, [0.5, 0.5]) - assert result["choice"] == "delete" - assert result["type"] == "choice" - assert list(result["probabilities"]) == ["delete", "cancel"] - assert answer(question, [0.2, 0.8])["choice"] == "cancel" +def test_answer(): + _, (question,) = mapped(body()) + tie = answer(question, [0.5, 0.5]) + assert tie["choice"] == "a" and tie["confidence"] == pytest.approx(0.0, abs=1e-12) + result = answer(question, [0.12, 0.88]) + assert result["choice"] == "b" + assert list(result["probabilities"]) == ["a", "b"] + h = -(0.12 * math.log(0.12) + 0.88 * math.log(0.88)) + assert result["confidence"] == pytest.approx(1 - h / math.log(2)) diff --git a/tests/cua_s1/test_text_model.py b/tests/cua_s1/test_text_model.py deleted file mode 100644 index 8dd0fd8..0000000 --- a/tests/cua_s1/test_text_model.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Adapter directory checks: no weights, no torch.""" - -import json - -import pytest - -from models.cua_s1.text.model import downloaded_revision, text_adapter_dir - -REV = "16818868b0cc7813808aae4e87b417657046ab79" - - -def write_adapter(path, targets): - path.mkdir(parents=True) - config = {"base_model_name_or_path": "Qwen/Qwen3.5-4B", "target_modules": targets} - (path / "adapter_config.json").write_text(json.dumps(config)) - - -def test_text_adapter_dir(tmp_path): - write_adapter(tmp_path / "text", ["q_proj", "down_proj"]) - write_adapter(tmp_path / "multimodal", ["q_proj", "linear_fc1"]) - assert text_adapter_dir(tmp_path) == tmp_path / "text" - assert text_adapter_dir(tmp_path / "text") == tmp_path / "text" - with pytest.raises(RuntimeError, match="multimodal adapter"): - text_adapter_dir(tmp_path / "multimodal") - - -def test_downloaded_revision_from_repository_root(tmp_path): - write_adapter(tmp_path / "text", ["q_proj"]) - meta = ( - tmp_path / ".cache/huggingface/download/text/adapter_model.safetensors.metadata" - ) - meta.parent.mkdir(parents=True) - meta.write_text(f"{REV}\nabc\n1\n") - assert downloaded_revision(tmp_path) == REV - assert downloaded_revision(tmp_path / "text") == REV - assert downloaded_revision(tmp_path / "missing") is None diff --git a/tests/cua_s1/test_text_server.py b/tests/cua_s1/test_text_server.py index ed5cb7f..bc4be9c 100644 --- a/tests/cua_s1/test_text_server.py +++ b/tests/cua_s1/test_text_server.py @@ -1,12 +1,9 @@ """HTTP tests for the worker with a fake model: no weights, no torch.""" import json -from dataclasses import dataclass import pytest -# The worker's own requirements include fastapi and httpx; skip where only the -# contract tests' dependencies are installed. pytest.importorskip("fastapi") pytest.importorskip("httpx") from fastapi.testclient import TestClient # noqa: E402 @@ -14,170 +11,75 @@ from frontend.cua_s1_text import build_app # noqa: E402 -@dataclass -class _Ids: - shape: tuple +class Ids: + def __init__(self, n): + self.shape = (1, n) class FakeModel: - device = "cpu" - dtype = "float32" - - def __init__(self, tokens=100, fail=False, nan=False): - self.tokens = tokens - self.fail = fail - self.nan = nan - self.forward_calls = 0 + def __init__(self, tokens=100, error=None, nan=False): + self.tokens, self.error, self.nan, self.calls = tokens, error, nan, 0 def encode(self, state, question): - return {"input_ids": _Ids(shape=(1, self.tokens))} - - def score_encoded(self, inputs, n_options): - self.forward_calls += 1 - if self.fail: - raise RuntimeError("CUDA out of memory") - - @dataclass - class Scored: - probabilities: list - prompt_tokens: int + return {"input_ids": Ids(self.tokens)} - probabilities = [0.1] * n_options - probabilities[-1] = 1.0 - 0.1 * (n_options - 1) - if self.nan: - probabilities[0] = float("nan") - return Scored(probabilities, inputs["input_ids"].shape[1]) - - -def client(model=None, api_key=None, max_body_bytes=4 << 20, max_prompt_tokens=32768): - app = build_app( - model or FakeModel(), - api_key=api_key, - max_body_bytes=max_body_bytes, - max_questions=64, - max_prompt_tokens=max_prompt_tokens, - revision="r", - ) - return TestClient(app) + def score(self, inputs, n_options): + self.calls += 1 + if self.error: + raise self.error + p = [0.1] * (n_options - 1) + [1 - 0.1 * (n_options - 1)] + return [float("nan")] + p[1:] if self.nan else p BODY = { "model": "cua-s1-4b-0.2", "state": "Screen", "questions": { - "q": { + "_sa": { "type": "choice", "instructions": "Pick.", - "criteria": {"a": "A", "b": "B"}, + "criteria": {"_x": "A", "b": "B"}, } }, } -def test_health(): - response = client().get("/health") - assert response.status_code == 200 - assert response.json()["model"] == "cua-ai/cua-s1-4b-0.2@r:text" - assert response.json()["status"] == "ready" - assert response.json()["modality"] == "text" - - -def test_choice_answer(): - response = client().post("/v1/systemone", json=BODY) - assert response.status_code == 200, response.text - body = response.json() - assert body["answers"]["q"]["type"] == "choice" - assert body["answers"]["q"]["choice"] == "b" - assert body["usage"] == {"input_tokens": 100, "output_tokens": 0} +def post(model=None, **kwargs): + return TestClient(build_app(model or FakeModel())).post("/v1/systemone", **kwargs) -def test_keys_come_back_as_sent(): - body = json.loads(json.dumps(BODY)) - body["questions"] = { - "_sample": { - "type": "choice", - "instructions": "Pick.", - "criteria": {"_save": "Save", "b": "B"}, - } - } - response = client().post("/v1/systemone", json=body) - assert response.status_code == 200, response.text - answers = response.json()["answers"] - assert list(answers) == ["_sample"] - assert list(answers["_sample"]["probabilities"]) == ["_save", "b"] - - -def test_chunked_upload(): - raw = json.dumps(BODY).encode() - response = client().post( - "/v1/systemone", - content=iter([raw[:10], raw[10:]]), - headers={"content-type": "application/json"}, - ) +def test_health_and_answer(): + app = build_app(FakeModel()) + assert TestClient(app).get("/health").json()["status"] == "ready" + response = post(json=BODY) assert response.status_code == 200, response.text + reply = response.json() + assert reply["answers"]["_sa"]["choice"] == "b" + assert list(reply["answers"]["_sa"]["probabilities"]) == ["_x", "b"] + assert reply["usage"] == {"input_tokens": 100, "output_tokens": 0} def test_errors(): - c = client() bad = json.loads(json.dumps(BODY)) - bad["questions"]["q"]["type"] = "noul" - response = c.post("/v1/systemone", json=bad) - assert response.status_code == 422 - assert "'noul' is not supported" in response.json()["detail"] - assert c.post("/v1/systemone", content=b"{").status_code == 400 - - -def test_limits(): - assert client(max_body_bytes=50).post("/v1/systemone", json=BODY).status_code == 413 - raw = json.dumps(BODY).encode() - streamed = client(max_body_bytes=50).post( - "/v1/systemone", - content=iter([raw[:40], raw[40:]]), - headers={"content-type": "application/json"}, - ) - assert streamed.status_code == 413 - model = FakeModel(tokens=40000) - body = json.loads(json.dumps(BODY)) - body["questions"]["r"] = body["questions"]["q"] - response = client(model).post("/v1/systemone", json=body) - assert response.status_code == 413 - assert "token limit" in response.json()["detail"] - assert model.forward_calls == 0 - - -@pytest.mark.parametrize("model", [FakeModel(fail=True), FakeModel(nan=True)]) -def test_model_failure_is_json_500(model): - response = client(model).post("/v1/systemone", json=BODY) + bad["questions"]["_sa"]["type"] = "noul" + assert post(json=bad).status_code == 422 + assert post(content=b"{").status_code == 400 + raw = b" " * (4 << 20) + json.dumps(BODY).encode() + assert post(content=iter([raw[:10], raw[10:]])).status_code == 413 + model = FakeModel(tokens=20000) + assert post(model, json=BODY).status_code == 413 and model.calls == 0 + + +@pytest.mark.parametrize( + "model", [FakeModel(error=RuntimeError("CUDA out of memory")), FakeModel(nan=True)] +) +def test_model_failure_is_500(model): + response = post(model, json=BODY) assert response.status_code == 500 assert response.json() == {"detail": "inference failed"} -def test_warmup_runs_the_request_path(): +def test_warmup(): model = FakeModel() - app = build_app( - model, - api_key=None, - max_body_bytes=1 << 20, - max_questions=64, - max_prompt_tokens=32768, - revision="r", - ) - app.state.warmup() - assert model.forward_calls == 1 - with pytest.raises(ValueError): - build_app( - FakeModel(nan=True), - api_key=None, - max_body_bytes=1 << 20, - max_questions=64, - max_prompt_tokens=32768, - revision="r", - ).state.warmup() - - -def test_bearer_token(): - c = client(api_key="secret") - assert c.post("/v1/systemone", json=BODY).status_code == 401 - assert c.get("/health").status_code == 200 - ok = c.post("/v1/systemone", json=BODY, headers={"Authorization": "Bearer secret"}) - assert ok.status_code == 200 + build_app(model).state.warmup() + assert model.calls == 1 diff --git a/tests/cua_s1/test_text_tokenizer.py b/tests/cua_s1/test_text_tokenizer.py deleted file mode 100644 index 90bcd94..0000000 --- a/tests/cua_s1/test_text_tokenizer.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Tokenizer checks against the pinned base model (tokenizer files only, no weights). - -Set CUA_S1_BASE to a local Qwen/Qwen3.5-4B directory to run them. -""" - -import json -import os -from pathlib import Path - -import pytest - -from models.cua_s1.text.contract import LETTERS, build_messages, map_request, parse_body - -BASE = os.environ.get("CUA_S1_BASE") -pytestmark = pytest.mark.skipif(not BASE, reason="set CUA_S1_BASE to run") - - -@pytest.fixture(scope="module") -def tokenizer(): - from transformers import AutoTokenizer - - return AutoTokenizer.from_pretrained(BASE) - - -def test_letter_ids(tokenizer): - ids = [tokenizer.encode(letter, add_special_tokens=False) for letter in LETTERS] - assert ids == [[32 + i] for i in range(26)] - - -def test_fixture_prompt(tokenizer): - inputs = json.loads( - (Path(__file__).parent / "data" / "text_inputs.json").read_text( - encoding="utf-8" - ) - ) - request = map_request(parse_body(json.dumps(inputs["fixture_positive"]).encode())) - text = tokenizer.apply_chat_template( - build_messages(request.state, request.questions[0]), - tokenize=False, - add_generation_prompt=True, - ) - assert text.endswith("<|im_start|>assistant\n\n") - ids = tokenizer(text)["input_ids"] - assert len(ids) == 218 - assert ids == tokenizer(text, add_special_tokens=False)["input_ids"]