makepad/tools/qwen38/test_api.py
Admin 384d0e031c tools: the Builder replaces makepad_loader, the web server moves to makepad/webserver, fleet scripts, docs and the workspace members
tools/makepad_builder replaces tools/makepad_loader: one build target
shared across app builds, workspace package selection, checkout
progress on the public Git API, detached built apps with a completion
state, waits for Windows security scans, manual retry after compiler
locks, dedicated-folder installer checks, catalog and runtime fixes.
tools/web_server and its scripts leave for github.com/makepad/webserver.
Arch USB clone/restore scripts, the qwen38 box scripts and the G-belt
serial test join tools/. docs/agents records the agent workflow and the
remote-control handoff protocol; AGENTS.md forbids vendored sources and
bulk imports. Cargo.toml lists apps/wm-dyn, libs/code_language,
libs/search, libs/tar, libs/loader_bundle and tools/makepad_builder,
and drops the two removed crates.

Squashed from work:
- Share Builder target across Makepad app builds
- Fix Builder workspace package selection
- Align Builder checkout progress with public Git API
- Detach built apps and show completion state
- Wait for Windows security scans
- Offer manual retry after Windows compiler locks
- docs: the agent workflow of record and the remote-control handoff protocol
- builder: dedicated-folder installer checks, catalog and runtime fixes; Windows job objects hold c_void handles
- tools: Arch USB clone/restore scripts, the qwen38 box scripts, and the G-belt serial test
- tools: the web server moves to makepad/webserver
- AGENTS.md: no vendored sources or bulk imports in the tree

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 12:17:59 +02:00

176 lines
7.4 KiB
Python

#!/usr/bin/env python3
"""Functional + auth tests for the Qwen3.8-27B llama-server endpoint on .217.
Usage: QWEN38_KEY=... python3 tools/qwen38/test_api.py [--host 10.0.0.217] [--port 8090]
Prints PASS/FAIL lines; exit 1 if any FAIL.
"""
import argparse, base64, json, os, struct, sys, time, urllib.request, urllib.error, zlib
def make_png():
# 64x64: left half red, right half blue, 8px white square center
w = h = 64
rows = []
for y in range(h):
row = bytearray([0]) # filter type 0
for x in range(w):
if 28 <= x < 36 and 28 <= y < 36:
px = (255, 255, 255)
elif x < w // 2:
px = (220, 30, 30)
else:
px = (30, 30, 220)
row += bytes(px)
rows.append(bytes(row))
raw = b"".join(rows)
def chunk(typ, data):
c = struct.pack(">I", len(data)) + typ + data
return c + struct.pack(">I", zlib.crc32(typ + data) & 0xFFFFFFFF)
ihdr = struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0)
return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr)
+ chunk(b"IDAT", zlib.compress(raw, 9)) + chunk(b"IEND", b""))
def req(base, key, path, body=None, timeout=180, method=None, raw=False):
url = base + path
data = json.dumps(body).encode() if body is not None else None
r = urllib.request.Request(url, data=data, method=method or ("POST" if data else "GET"))
r.add_header("Content-Type", "application/json")
if key is not None:
r.add_header("Authorization", "Bearer " + key)
try:
with urllib.request.urlopen(r, timeout=timeout) as resp:
b = resp.read()
return resp.status, (b if raw else json.loads(b))
except urllib.error.HTTPError as e:
return e.code, e.read().decode(errors="replace")[:300]
except Exception as e:
return -1, str(e)
results = []
def check(name, ok, detail=""):
results.append((name, ok))
print(("PASS " if ok else "FAIL ") + name + (" | " + str(detail)[:400] if detail else ""))
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--host", default="10.0.0.217")
ap.add_argument("--port", default="8090")
args = ap.parse_args()
key = os.environ.get("QWEN38_KEY")
if not key:
print("set QWEN38_KEY"); sys.exit(2)
base = f"http://{args.host}:{args.port}"
st, _ = req(base, None, "/health")
check("health_no_auth", st == 200, st)
st, body = req(base, None, "/v1/chat/completions", {
"messages": [{"role": "user", "content": "hi"}], "max_tokens": 4})
check("unauth_rejected_401", st == 401, st)
st, body = req(base, "wrong-key-123", "/v1/chat/completions", {
"messages": [{"role": "user", "content": "hi"}], "max_tokens": 4})
check("wrongkey_rejected_401", st == 401, st)
st, body = req(base, key, "/v1/models")
ok = st == 200 and any("qwen3.8" in m.get("id", "").lower() for m in body.get("data", []))
check("models_lists_qwen38", ok, body if not ok else body["data"][0]["id"])
# thinking (default) math — low effort to keep it quick, greedy
st, body = req(base, key, "/v1/chat/completions", {
"messages": [{"role": "user", "content": "What is 84 * 3 / 2? Reply with just the number."}],
"max_tokens": 1200, "temperature": 0,
"chat_template_kwargs": {"reasoning_effort": "low"}})
if st == 200:
msg = body["choices"][0]["message"]
content = msg.get("content") or ""
rc = msg.get("reasoning_content") or ""
check("think_math_126", "126" in content, content[:120])
check("think_reasoning_present", len(rc) > 0, ("rc_len=" + str(len(rc))))
t = body.get("timings", {})
print(f" timings: prompt_n={t.get('prompt_n')} prompt_ms={t.get('prompt_ms')} "
f"pred_n={t.get('predicted_n')} pred_per_sec={round(t.get('predicted_per_second') or 0,1)}")
else:
check("think_math_126", False, body)
# non-thinking
st, body = req(base, key, "/v1/chat/completions", {
"messages": [{"role": "user", "content": "What is 84 * 3 / 2? Reply with just the number."}],
"max_tokens": 60, "temperature": 0,
"chat_template_kwargs": {"enable_thinking": False}})
if st == 200:
msg = body["choices"][0]["message"]
content = msg.get("content") or ""
rc = msg.get("reasoning_content") or ""
check("nothink_math_126", "126" in content, content[:120])
check("nothink_no_reasoning", len(rc.strip()) == 0 and "<think>" not in content, "rc_len=" + str(len(rc)))
else:
check("nothink_math_126", False, body)
# xhigh (default) effort quick probe — verify template accepts default path
st, body = req(base, key, "/v1/chat/completions", {
"messages": [{"role": "user", "content": "Say OK."}],
"max_tokens": 2500, "temperature": 0})
ok = st == 200 and (body["choices"][0]["message"].get("content") or "").strip() != ""
check("default_effort_completes", ok,
(body["choices"][0]["message"].get("content") or "")[:80] if st == 200 else body)
# tool call
tools = [{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get current weather for a city",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string", "description": "City name"}},
"required": ["city"]}}}]
st, body = req(base, key, "/v1/chat/completions", {
"messages": [{"role": "user", "content": "What's the weather in Amsterdam right now? Use the tool."}],
"tools": tools, "max_tokens": 1200, "temperature": 0,
"chat_template_kwargs": {"reasoning_effort": "low"}})
if st == 200:
msg = body["choices"][0]["message"]
tcs = msg.get("tool_calls") or []
okname = tcs and tcs[0]["function"]["name"] == "get_weather"
argok = False
if okname:
try:
a = json.loads(tcs[0]["function"]["arguments"])
argok = "amsterdam" in json.dumps(a).lower()
except Exception:
argok = False
check("toolcall_parsed", bool(okname), json.dumps(tcs)[:300] if tcs else ("content=" + (msg.get("content") or "")[:200]))
check("toolcall_args_city", argok, tcs[0]["function"]["arguments"][:160] if tcs else "")
check("toolcall_finish_reason", body["choices"][0].get("finish_reason") == "tool_calls",
body["choices"][0].get("finish_reason"))
else:
check("toolcall_parsed", False, body)
# vision
png64 = base64.b64encode(make_png()).decode()
st, body = req(base, key, "/v1/chat/completions", {
"messages": [{"role": "user", "content": [
{"type": "text", "text": "Left half color and right half color of this image, one word each."},
{"type": "image_url", "image_url": {"url": "data:image/png;base64," + png64}}]}],
"max_tokens": 1200, "temperature": 0,
"chat_template_kwargs": {"reasoning_effort": "low"}}, timeout=300)
if st == 200:
content = (body["choices"][0]["message"].get("content") or "").lower()
check("vision_red_blue", ("red" in content and "blue" in content), content[:200])
else:
check("vision_red_blue", False, body)
n_fail = sum(1 for _, ok in results if not ok)
print(f"== {len(results) - n_fail}/{len(results)} passed ==")
sys.exit(1 if n_fail else 0)
if __name__ == "__main__":
main()