thx02 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- thx02/__init__.py +38 -0
- thx02/__main__.py +3 -0
- thx02/_local.py +17 -0
- thx02/build_thinker.py +50 -0
- thx02/cli.py +213 -0
- thx02/client.py +193 -0
- thx02/engine.py +77 -0
- thx02/fast.py +398 -0
- thx02/model.py +282 -0
- thx02/protocol.py +263 -0
- thx02/py.typed +0 -0
- thx02/runtime.py +144 -0
- thx02/serve.py +211 -0
- thx02/types.py +85 -0
- thx02-1.0.0.dist-info/METADATA +254 -0
- thx02-1.0.0.dist-info/RECORD +21 -0
- thx02-1.0.0.dist-info/WHEEL +5 -0
- thx02-1.0.0.dist-info/entry_points.txt +4 -0
- thx02-1.0.0.dist-info/licenses/LICENSE +202 -0
- thx02-1.0.0.dist-info/licenses/NOTICE +12 -0
- thx02-1.0.0.dist-info/top_level.txt +1 -0
thx02/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""THX-02: a decision model. Typed questions in, calibrated probabilities out, optional short thinking.
|
|
2
|
+
|
|
3
|
+
pip install thx02 # API client and the `thx02` command (no dependencies, works in Termux)
|
|
4
|
+
pip install "thx02[local]" # also run the model on your own GPU or CPU
|
|
5
|
+
|
|
6
|
+
from thx02 import Client
|
|
7
|
+
thx = Client() # reads HALX_API_KEY
|
|
8
|
+
thx.decide("I was charged twice", {"team": thx.choice("Which team?", billing="Charges", shipping="Deliveries")})
|
|
9
|
+
|
|
10
|
+
from thx02 import THX02 # local model (needs the [local] extra)
|
|
11
|
+
m = THX02.load("HAL-X-AI/THX-02")
|
|
12
|
+
"""
|
|
13
|
+
from .client import APIError, Client, choice, image, noul, score
|
|
14
|
+
|
|
15
|
+
__version__ = "1.0.0"
|
|
16
|
+
|
|
17
|
+
_LOCAL = {
|
|
18
|
+
"THX02": "runtime", "Decider": "model", "FastOptions": "model", "THXDecisionModel": "model",
|
|
19
|
+
"DEFAULT_MAX_LENGTH": "model", "MAX_OPTIONS": "protocol", "LengthError": "protocol", "RequestError": "protocol",
|
|
20
|
+
"answer": "protocol", "options": "protocol", "Answer": "types", "Question": "types", "Request": "types",
|
|
21
|
+
"Response": "types", "Usage": "types",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def __getattr__(name):
|
|
26
|
+
"""The local model classes import torch, so they load only when first used."""
|
|
27
|
+
module = _LOCAL.get(name)
|
|
28
|
+
if module is None:
|
|
29
|
+
raise AttributeError(f"module 'thx02' has no attribute {name!r}")
|
|
30
|
+
try:
|
|
31
|
+
from . import _local # noqa: F401
|
|
32
|
+
except ImportError as e:
|
|
33
|
+
raise ImportError(f"thx02.{name} runs the model locally and needs the extra: pip install \"thx02[local]\" ({e})") from e
|
|
34
|
+
import importlib
|
|
35
|
+
return getattr(importlib.import_module(f".{module}", __name__), name)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
__all__ = ["APIError", "Client", "choice", "image", "noul", "score", "__version__", *_LOCAL]
|
thx02/__main__.py
ADDED
thx02/_local.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Imported on first use of the local model classes: torch setup that the API client never needs."""
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
import torch
|
|
7
|
+
|
|
8
|
+
if os.environ.get("THX02_DEVICE") == "cpu" or not torch.cuda.is_available():
|
|
9
|
+
sys.modules.setdefault("fla", None) # GPU-only Triton kernels: use the PyTorch linear-attention path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class _QuietTypeCheck(logging.Filter): # config.json is THX-02's own; the backbone config sits inside it
|
|
13
|
+
def filter(self, record):
|
|
14
|
+
return "model of type `thx02`" not in record.getMessage()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
logging.getLogger("transformers.configuration_utils").addFilter(_QuietTypeCheck())
|
thx02/build_thinker.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Build the THX-02 thinker for vLLM: decision weights + think LoRA merged, plus the MTP head for speculative decoding.
|
|
2
|
+
|
|
3
|
+
python -m thx02.build_thinker --model . --out thinker-merged
|
|
4
|
+
vllm serve thinker-merged --served-model-name thx02-thinker --max-model-len 8192 \\
|
|
5
|
+
--speculative-config '{"method":"mtp","num_speculative_tokens":3}' # add --quantization fp8 on 24 GB GPUs
|
|
6
|
+
|
|
7
|
+
The thinker writes the short reasoning notes; decisions are always read by THX-02 itself.
|
|
8
|
+
"""
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import shutil
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main(argv=None):
|
|
16
|
+
from safetensors import safe_open
|
|
17
|
+
from safetensors.torch import load_file, save_file
|
|
18
|
+
ap = argparse.ArgumentParser(prog="thx02-build-thinker")
|
|
19
|
+
ap.add_argument("--model", default=".")
|
|
20
|
+
ap.add_argument("--out", default="thinker-merged")
|
|
21
|
+
a = ap.parse_args(argv)
|
|
22
|
+
root, out = Path(a.model), Path(a.out)
|
|
23
|
+
th = root / "thinker"
|
|
24
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
sd = {}
|
|
26
|
+
with safe_open(str(root / "model.safetensors"), "pt") as fh:
|
|
27
|
+
for k in fh.keys():
|
|
28
|
+
sd[k] = fh.get_tensor(k)
|
|
29
|
+
acfg = json.loads((th / "adapter_config.json").read_text())
|
|
30
|
+
scale = acfg["lora_alpha"] / acfg["r"]
|
|
31
|
+
lw = load_file(str(th / "adapter_model.safetensors"))
|
|
32
|
+
merged = 0
|
|
33
|
+
for k, A in lw.items():
|
|
34
|
+
if ".lora_A." not in k:
|
|
35
|
+
continue
|
|
36
|
+
B = lw[k.replace(".lora_A.", ".lora_B.")]
|
|
37
|
+
name = k.replace("base_model.model.", "").replace(".lora_A.weight", ".weight")
|
|
38
|
+
W = sd[name]
|
|
39
|
+
sd[name] = (W.float() + scale * (B.float() @ A.float())).to(W.dtype)
|
|
40
|
+
merged += 1
|
|
41
|
+
out_sd = {"model." + k: v.contiguous() for k, v in sd.items()}
|
|
42
|
+
out_sd.update(load_file(str(th / "mtp.safetensors")))
|
|
43
|
+
save_file(out_sd, str(out / "model.safetensors"), metadata={"format": "pt"})
|
|
44
|
+
for f in (th / "lm_config").iterdir():
|
|
45
|
+
shutil.copy(f, out / f.name)
|
|
46
|
+
print(f"merged {merged} LoRA modules; thinker written to {out}")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
if __name__ == "__main__":
|
|
50
|
+
main()
|
thx02/cli.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""`thx02`: THX decisions from any terminal, including Termux on Android.
|
|
2
|
+
|
|
3
|
+
thx02 "I was charged twice, refund please" --choice team=billing,support,sales --yesno "Refund request?"
|
|
4
|
+
thx02 "Income 4200, debts 2800, limit 50%. Pass?" --choice verdict=pass,fail --think on --thoughts
|
|
5
|
+
thx02 --image receipt.jpg --yesno "Is the receipt legible?"
|
|
6
|
+
thx02 --batch tickets.txt --choice team=billing,shipping > answers.jsonl
|
|
7
|
+
thx02 interactive: type states, get decisions
|
|
8
|
+
thx02 models models your key can use
|
|
9
|
+
thx02 serve ... run your own THX-02 server (pip install "thx02[server]")
|
|
10
|
+
|
|
11
|
+
The API key comes from --key or $HALX_API_KEY. --local runs the model on this machine (pip install "thx02[local]").
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
import time
|
|
20
|
+
|
|
21
|
+
from . import __version__
|
|
22
|
+
from .client import APIError, Client, image, top
|
|
23
|
+
|
|
24
|
+
RUBY, DIM, BOLD, RESET = "\033[38;2;226;23;102m", "\033[2m", "\033[1m", "\033[0m"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _color() -> bool:
|
|
28
|
+
return sys.stdout.isatty() and not os.environ.get("NO_COLOR")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _options(spec: str, flag: str):
|
|
32
|
+
if "=" not in spec:
|
|
33
|
+
raise SystemExit(f"{flag} needs NAME=option1,option2 (got {spec!r})")
|
|
34
|
+
name, opts = spec.split("=", 1)
|
|
35
|
+
items = [o.strip() for o in opts.split(",") if o.strip()]
|
|
36
|
+
if len(items) < 2:
|
|
37
|
+
raise SystemExit(f"{flag} {name}: give at least two options")
|
|
38
|
+
return name.strip(), items
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def build_questions(a) -> dict:
|
|
42
|
+
qs = {}
|
|
43
|
+
for spec in a.choice or []:
|
|
44
|
+
name, items = _options(spec, "--choice")
|
|
45
|
+
crit = {}
|
|
46
|
+
for it in items: # "key:description" or just "key"
|
|
47
|
+
k, _, d = it.partition(":")
|
|
48
|
+
crit[k.strip()] = (d or k).strip()
|
|
49
|
+
qs[name] = {"type": "choice", "instructions": a.instructions or f"Which {name} applies?", "criteria": crit}
|
|
50
|
+
for spec in a.score or []:
|
|
51
|
+
name, items = _options(spec, "--score")
|
|
52
|
+
qs[name] = {"type": "score", "instructions": a.instructions or f"How would you rate the {name}?", "criteria": items}
|
|
53
|
+
for i, text in enumerate(a.yesno or []):
|
|
54
|
+
name = text.strip().rstrip("?")[:60] # the question itself labels its answer
|
|
55
|
+
qs[name if name not in qs else f"{name} ({i + 1})"] = {"type": "noul", "instructions": text}
|
|
56
|
+
if a.questions:
|
|
57
|
+
raw = open(a.questions, encoding="utf-8").read() if os.path.exists(a.questions) else a.questions
|
|
58
|
+
qs.update(json.loads(raw))
|
|
59
|
+
return qs or {"intent": {"type": "choice", "instructions": "What does the user want?", "criteria": {
|
|
60
|
+
"question": "Asks a question", "complaint": "Complains about something", "request": "Asks for an action",
|
|
61
|
+
"other": "Something else"}}}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def show(out: dict, color: bool) -> None:
|
|
65
|
+
r, d, b, x = (RUBY, DIM, BOLD, RESET) if color else ("", "", "", "")
|
|
66
|
+
for name, a in out.get("answers", {}).items():
|
|
67
|
+
if a.get("type") == "noul":
|
|
68
|
+
dist = {"yes": a["noul"], "no": 1 - a["noul"]}
|
|
69
|
+
else:
|
|
70
|
+
legend = a.get("legend") or {}
|
|
71
|
+
dist = {legend.get(k, k): v for k, v in a.get("probabilities", {}).items()}
|
|
72
|
+
best, _ = top(a)
|
|
73
|
+
print(f"\n{b}{name}{x}")
|
|
74
|
+
for k, p in sorted(dist.items(), key=lambda kv: -kv[1]):
|
|
75
|
+
bar = "█" * round(p * 24)
|
|
76
|
+
mark = f"{r}›{x}" if k == best else " "
|
|
77
|
+
print(f" {mark} {k[:24]:24s} {r if k == best else d}{bar:24s}{x} {p * 100:5.1f}%")
|
|
78
|
+
if a.get("thought"):
|
|
79
|
+
print(f" {d}thought:{x} {a['thought']}")
|
|
80
|
+
usage = out.get("usage") or {}
|
|
81
|
+
extra = f" · {usage.get('thoughts')} thought(s)" if usage.get("thoughts") else ""
|
|
82
|
+
print(f"\n{d}{out.get('model', '')} · {out.get('latency_ms', '?')} ms{extra}{x}")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _local_runner(path: str):
|
|
86
|
+
os.environ.setdefault("THX02_DEVICE", "cpu")
|
|
87
|
+
try:
|
|
88
|
+
import torch
|
|
89
|
+
from . import THX02
|
|
90
|
+
except ImportError:
|
|
91
|
+
raise SystemExit('--local needs the model runtime: pip install "thx02[local]"') from None
|
|
92
|
+
device = "cuda" if torch.cuda.is_available() and os.environ.get("THX02_DEVICE") != "cpu" else "cpu"
|
|
93
|
+
print(f"loading THX-02 on {device} ...", file=sys.stderr)
|
|
94
|
+
m = THX02.load(path, device=device)
|
|
95
|
+
return lambda body: m.decide(body["state"], body["questions"], images=body.get("images"), think=False,
|
|
96
|
+
tta=body.get("tta"))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def main(argv=None) -> int:
|
|
100
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
101
|
+
if argv[:1] == ["serve"]:
|
|
102
|
+
try:
|
|
103
|
+
from .serve import main as serve_main
|
|
104
|
+
except ImportError:
|
|
105
|
+
raise SystemExit('thx02 serve needs the server extra: pip install "thx02[server]"') from None
|
|
106
|
+
sys.argv = ["thx02-serve", *argv[1:]]
|
|
107
|
+
return serve_main() or 0
|
|
108
|
+
|
|
109
|
+
ap = argparse.ArgumentParser(prog="thx02", description="THX decisions from the terminal (api.hal-x.ai or local).",
|
|
110
|
+
epilog="Docs: https://api.hal-x.ai/docs/thx-02/")
|
|
111
|
+
ap.add_argument("state", nargs="?", help='text to decide about, or "models"; omit for interactive mode')
|
|
112
|
+
ap.add_argument("--choice", action="append", metavar="NAME=a,b,c", help="pick one option (a:description works too)")
|
|
113
|
+
ap.add_argument("--yesno", action="append", metavar="QUESTION", help="yes / no question")
|
|
114
|
+
ap.add_argument("--score", action="append", metavar="NAME=low,mid,high", help="ordered scale, lowest first")
|
|
115
|
+
ap.add_argument("--instructions", "-i", help="question text for --choice / --score")
|
|
116
|
+
ap.add_argument("--questions", "-q", help="questions as JSON (a string or a file)")
|
|
117
|
+
ap.add_argument("--image", action="append", help="image file to include (up to 4)")
|
|
118
|
+
ap.add_argument("--think", choices=["off", "auto", "on"], default="off", help="thinking mode (default off)")
|
|
119
|
+
ap.add_argument("--thoughts", action="store_true", help="show the thoughts")
|
|
120
|
+
ap.add_argument("--tta", type=int, choices=[1, 4], help="read choice options in 4 orders and average")
|
|
121
|
+
ap.add_argument("--batch", metavar="FILE", help="one state per line (or JSONL with a state field); prints JSONL")
|
|
122
|
+
ap.add_argument("--model", default=os.environ.get("THX_MODEL", "thx-02"), help="thx-02 (default) or thx-01")
|
|
123
|
+
ap.add_argument("--key", help="API key (default $HALX_API_KEY)")
|
|
124
|
+
ap.add_argument("--server", default=os.environ.get("HALX_BASE_URL"), help="API base URL (default https://api.hal-x.ai)")
|
|
125
|
+
ap.add_argument("--local", metavar="MODEL", help="run on this machine: a THX-02 folder or HAL-X-AI/THX-02")
|
|
126
|
+
ap.add_argument("--json", action="store_true", help="print raw JSON")
|
|
127
|
+
ap.add_argument("--version", action="version", version=f"thx02 {__version__}")
|
|
128
|
+
a = ap.parse_args(argv)
|
|
129
|
+
color = _color() and not a.json
|
|
130
|
+
|
|
131
|
+
client = Client(api_key=a.key, base_url=a.server, model=a.model)
|
|
132
|
+
if a.state == "models" and not a.local:
|
|
133
|
+
try:
|
|
134
|
+
print("\n".join(client.models()))
|
|
135
|
+
except APIError as e:
|
|
136
|
+
print(f"error: {e}", file=sys.stderr)
|
|
137
|
+
return 1
|
|
138
|
+
return 0
|
|
139
|
+
|
|
140
|
+
qs = build_questions(a)
|
|
141
|
+
images = [image(f) for f in (a.image or [])][:4]
|
|
142
|
+
think = {"off": False, "auto": "auto", "on": True}[a.think]
|
|
143
|
+
run_local = _local_runner(a.local) if a.local else None
|
|
144
|
+
|
|
145
|
+
def body_for(state):
|
|
146
|
+
b = {"state": state, "questions": qs}
|
|
147
|
+
if images:
|
|
148
|
+
b["images"] = images
|
|
149
|
+
if think:
|
|
150
|
+
b["think"] = think
|
|
151
|
+
if a.thoughts:
|
|
152
|
+
b["return_thoughts"] = True
|
|
153
|
+
if a.tta:
|
|
154
|
+
b["tta"] = a.tta
|
|
155
|
+
return b
|
|
156
|
+
|
|
157
|
+
def ask(state) -> int:
|
|
158
|
+
t0 = time.time()
|
|
159
|
+
try:
|
|
160
|
+
if run_local:
|
|
161
|
+
out = run_local(body_for(state))
|
|
162
|
+
else:
|
|
163
|
+
b = body_for(state)
|
|
164
|
+
out = client.decide(b["state"], b["questions"], images=b.get("images"), think=think,
|
|
165
|
+
return_thoughts=a.thoughts, tta=a.tta)
|
|
166
|
+
except APIError as e:
|
|
167
|
+
print(f"error: {e}", file=sys.stderr)
|
|
168
|
+
if e.status == 401 and not client.api_key:
|
|
169
|
+
print("Set your key: export HALX_API_KEY=sk-... (or --key). Docs: https://api.hal-x.ai/docs/thx-02/", file=sys.stderr)
|
|
170
|
+
return 1
|
|
171
|
+
out.setdefault("latency_ms", round((time.time() - t0) * 1000))
|
|
172
|
+
if a.json:
|
|
173
|
+
print(json.dumps(out, ensure_ascii=False, indent=2))
|
|
174
|
+
else:
|
|
175
|
+
show(out, color)
|
|
176
|
+
return 0
|
|
177
|
+
|
|
178
|
+
if a.batch:
|
|
179
|
+
lines = [ln.strip() for ln in open(a.batch, encoding="utf-8") if ln.strip()]
|
|
180
|
+
states = [json.loads(ln).get("state", "") if ln.startswith("{") else ln for ln in lines]
|
|
181
|
+
for i in range(0, len(states), 256):
|
|
182
|
+
chunk = states[i:i + 256]
|
|
183
|
+
try:
|
|
184
|
+
results = client.batch([body_for(s) for s in chunk])
|
|
185
|
+
except APIError as e:
|
|
186
|
+
print(f"error: {e}", file=sys.stderr)
|
|
187
|
+
return 1
|
|
188
|
+
for s, res in zip(chunk, results):
|
|
189
|
+
print(json.dumps({"state": s, **res}, ensure_ascii=False))
|
|
190
|
+
return 0
|
|
191
|
+
|
|
192
|
+
if a.state is not None:
|
|
193
|
+
return ask(a.state)
|
|
194
|
+
if images:
|
|
195
|
+
return ask("")
|
|
196
|
+
if not sys.stdin.isatty():
|
|
197
|
+
return ask(sys.stdin.read().strip())
|
|
198
|
+
r, d, x = (RUBY, DIM, RESET) if color else ("", "", "")
|
|
199
|
+
print(f"{r}THX-02{x} interactive · questions: {', '.join(qs)} · empty line to quit")
|
|
200
|
+
while True:
|
|
201
|
+
try:
|
|
202
|
+
s = input(f"\n{d}state>{x} ").strip()
|
|
203
|
+
except (EOFError, KeyboardInterrupt):
|
|
204
|
+
print()
|
|
205
|
+
break
|
|
206
|
+
if not s:
|
|
207
|
+
break
|
|
208
|
+
ask(s)
|
|
209
|
+
return 0
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
if __name__ == "__main__":
|
|
213
|
+
sys.exit(main())
|
thx02/client.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""THX-02 API client. Standard library only, so it installs anywhere Python runs (servers, laptops, Termux).
|
|
2
|
+
|
|
3
|
+
from thx02 import Client, choice, noul, score
|
|
4
|
+
|
|
5
|
+
thx = Client() # API key from HALX_API_KEY
|
|
6
|
+
out = thx.decide(
|
|
7
|
+
"Salam, kartımdan iki dəfə pul çıxılıb, geri qaytarın zəhmət olmasa.",
|
|
8
|
+
{"team": choice("Which team should handle this?", billing="Charges and refunds", support="Technical problems"),
|
|
9
|
+
"refund": noul("Does the customer ask for a refund?"),
|
|
10
|
+
"anger": score("How frustrated is the customer?", ["calm", "annoyed", "angry"])},
|
|
11
|
+
think="auto",
|
|
12
|
+
)
|
|
13
|
+
out["answers"]["team"]["choice"] # "billing"
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import base64
|
|
18
|
+
import json
|
|
19
|
+
import mimetypes
|
|
20
|
+
import os
|
|
21
|
+
import time
|
|
22
|
+
import urllib.error
|
|
23
|
+
import urllib.request
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any, Dict, Iterable, List, Optional, Sequence, Union
|
|
26
|
+
|
|
27
|
+
DEFAULT_BASE_URL = "https://api.hal-x.ai"
|
|
28
|
+
DEFAULT_MODEL = "thx-02"
|
|
29
|
+
RETRY_STATUS = {429, 502, 503, 504}
|
|
30
|
+
USER_AGENT = "thx02-python/1.0.0"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class APIError(Exception):
|
|
34
|
+
"""An error answer from the API (or the network). `status` is the HTTP status (0 for network errors)."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, status: int, message: str, code: str = "", request_id: str = "", body: Any = None):
|
|
37
|
+
super().__init__(f"{status} {code}: {message}" if code else f"{status}: {message}")
|
|
38
|
+
self.status, self.message, self.code, self.request_id, self.body = status, message, code, request_id, body
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ------------------------------------------------------------------ question builders
|
|
42
|
+
|
|
43
|
+
def choice(instructions: str, options: Union[Dict[str, str], Sequence[str], None] = None, **described: str) -> dict:
|
|
44
|
+
"""One option out of a list. Options as a dict {key: description}, a list of keys, or keyword arguments."""
|
|
45
|
+
if options is None:
|
|
46
|
+
options = {}
|
|
47
|
+
crit = {str(o): str(o) for o in options} if not isinstance(options, dict) else dict(options)
|
|
48
|
+
crit.update(described)
|
|
49
|
+
if len(crit) < 2:
|
|
50
|
+
raise ValueError("a choice question needs at least two options")
|
|
51
|
+
return {"type": "choice", "instructions": instructions, "criteria": crit}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def noul(instructions: str) -> dict:
|
|
55
|
+
"""A yes / no question; the answer is the probability of yes."""
|
|
56
|
+
return {"type": "noul", "instructions": instructions}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def score(instructions: str, levels: Sequence[str]) -> dict:
|
|
60
|
+
"""An ordered scale, lowest level first."""
|
|
61
|
+
if len(levels) < 2:
|
|
62
|
+
raise ValueError("a score question needs at least two levels")
|
|
63
|
+
return {"type": "score", "instructions": instructions, "criteria": [str(x) for x in levels]}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def image(source: Union[str, Path, bytes], mime: Optional[str] = None) -> str:
|
|
67
|
+
"""A data URI from a file path or raw bytes (PNG, JPEG, WebP). Data URIs and http(s) strings pass through."""
|
|
68
|
+
if isinstance(source, str) and source.startswith(("data:", "http://", "https://")):
|
|
69
|
+
return source
|
|
70
|
+
if isinstance(source, (str, Path)):
|
|
71
|
+
mime = mime or mimetypes.guess_type(str(source))[0] or "image/jpeg"
|
|
72
|
+
source = Path(source).expanduser().read_bytes()
|
|
73
|
+
return f"data:{mime or 'image/jpeg'};base64," + base64.b64encode(source).decode()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def top(answer: dict):
|
|
77
|
+
"""(label, probability) of the most likely option of one answer."""
|
|
78
|
+
if answer.get("type") == "noul":
|
|
79
|
+
p = float(answer["noul"])
|
|
80
|
+
return ("yes", p) if p >= 0.5 else ("no", 1 - p)
|
|
81
|
+
probs = answer.get("probabilities") or {}
|
|
82
|
+
best = max(probs, key=probs.get)
|
|
83
|
+
label = (answer.get("legend") or {}).get(best, best)
|
|
84
|
+
return label, float(probs[best])
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ------------------------------------------------------------------ client
|
|
88
|
+
|
|
89
|
+
class Client:
|
|
90
|
+
"""Calls THX models on api.hal-x.ai (or any THX-02 server with base_url).
|
|
91
|
+
|
|
92
|
+
api_key defaults to $HALX_API_KEY (or $THX_API_KEY). Without one, only the free tier answers (per-IP limits).
|
|
93
|
+
base_url defaults to $HALX_BASE_URL or https://api.hal-x.ai. A self-hosted `thx02-serve` works too.
|
|
94
|
+
model "thx-02" (default) or "thx-01".
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None, model: str = DEFAULT_MODEL,
|
|
98
|
+
timeout: float = 120.0, max_retries: int = 3):
|
|
99
|
+
self.api_key = api_key or os.environ.get("HALX_API_KEY") or os.environ.get("THX_API_KEY") or ""
|
|
100
|
+
self.base_url = (base_url or os.environ.get("HALX_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
101
|
+
self.model, self.timeout, self.max_retries = model, timeout, max_retries
|
|
102
|
+
|
|
103
|
+
# -- public API
|
|
104
|
+
|
|
105
|
+
def decide(self, state: Any, questions: Dict[str, dict], *, images: Optional[Iterable] = None,
|
|
106
|
+
think: Union[bool, str] = False, return_thoughts: bool = False, tta: Optional[int] = None,
|
|
107
|
+
model: Optional[str] = None) -> dict:
|
|
108
|
+
"""One state, its questions. Returns {"model", "answers", "usage", "latency_ms"}.
|
|
109
|
+
|
|
110
|
+
think: False (direct), "auto" (think only when unsure and reasoning helps) or True (think on nearly every question).
|
|
111
|
+
images: up to 4 paths, bytes or data URIs. tta: 4 reads choice options in four orders and averages.
|
|
112
|
+
"""
|
|
113
|
+
body = self._body(state, questions, images, think, return_thoughts, tta)
|
|
114
|
+
body["model"] = model or self.model
|
|
115
|
+
return self._post("/v1/decide", body)
|
|
116
|
+
|
|
117
|
+
def batch(self, items: Sequence[Union[dict, tuple]], *, model: Optional[str] = None, **defaults) -> List[dict]:
|
|
118
|
+
"""Up to 1,024 states in one call. Items are dicts (state, questions, images, think, ...) or (state, questions)
|
|
119
|
+
pairs; keyword arguments (think=..., tta=...) apply to every item. Failed items come back as {"error": ...}."""
|
|
120
|
+
out_items = []
|
|
121
|
+
for it in items:
|
|
122
|
+
if isinstance(it, tuple):
|
|
123
|
+
it = {"state": it[0], "questions": it[1]}
|
|
124
|
+
merged = {**defaults, **it}
|
|
125
|
+
out_items.append(self._body(merged.get("state", ""), merged["questions"], merged.get("images"),
|
|
126
|
+
merged.get("think", False), merged.get("return_thoughts", False), merged.get("tta")))
|
|
127
|
+
return self._post("/v1/decide/batch", {"model": model or self.model, "items": out_items})["results"]
|
|
128
|
+
|
|
129
|
+
def models(self) -> List[str]:
|
|
130
|
+
"""Model ids your key can use."""
|
|
131
|
+
return [m["id"] for m in self._request("GET", "/v1/models").get("data", [])]
|
|
132
|
+
|
|
133
|
+
choice, noul, score, image, top = staticmethod(choice), staticmethod(noul), staticmethod(score), staticmethod(image), staticmethod(top)
|
|
134
|
+
|
|
135
|
+
# -- internals
|
|
136
|
+
|
|
137
|
+
@staticmethod
|
|
138
|
+
def _body(state, questions, images, think, return_thoughts, tta) -> dict:
|
|
139
|
+
if not isinstance(questions, dict) or not questions:
|
|
140
|
+
raise ValueError("questions must be a non-empty dict of named questions")
|
|
141
|
+
body: Dict[str, Any] = {"state": state, "questions": questions}
|
|
142
|
+
imgs = [image(i) for i in (images or [])]
|
|
143
|
+
if imgs:
|
|
144
|
+
body["images"] = imgs
|
|
145
|
+
if think:
|
|
146
|
+
body["think"] = think
|
|
147
|
+
if return_thoughts:
|
|
148
|
+
body["return_thoughts"] = True
|
|
149
|
+
if tta:
|
|
150
|
+
body["tta"] = int(tta)
|
|
151
|
+
return body
|
|
152
|
+
|
|
153
|
+
def _post(self, path: str, body: dict) -> dict:
|
|
154
|
+
return self._request("POST", path, body)
|
|
155
|
+
|
|
156
|
+
def _request(self, method: str, path: str, body: Optional[dict] = None) -> dict:
|
|
157
|
+
data = json.dumps(body, ensure_ascii=False).encode() if body is not None else None
|
|
158
|
+
headers = {"Content-Type": "application/json", "Accept": "application/json", "User-Agent": USER_AGENT}
|
|
159
|
+
if self.api_key:
|
|
160
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
161
|
+
delay = 0.5
|
|
162
|
+
for attempt in range(self.max_retries + 1):
|
|
163
|
+
req = urllib.request.Request(self.base_url + path, data=data, headers=headers, method=method)
|
|
164
|
+
try:
|
|
165
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as r:
|
|
166
|
+
return json.loads(r.read().decode("utf-8"))
|
|
167
|
+
except urllib.error.HTTPError as e:
|
|
168
|
+
raw = e.read().decode("utf-8", "replace")
|
|
169
|
+
if e.code in RETRY_STATUS and attempt < self.max_retries:
|
|
170
|
+
retry_after = e.headers.get("Retry-After")
|
|
171
|
+
time.sleep(min(float(retry_after), 30.0) if retry_after and retry_after.isdigit() else delay)
|
|
172
|
+
delay *= 2
|
|
173
|
+
continue
|
|
174
|
+
raise _api_error(e.code, raw, e.headers.get("X-Request-ID", "")) from None
|
|
175
|
+
except (urllib.error.URLError, TimeoutError, ConnectionError) as e:
|
|
176
|
+
if attempt < self.max_retries:
|
|
177
|
+
time.sleep(delay)
|
|
178
|
+
delay *= 2
|
|
179
|
+
continue
|
|
180
|
+
raise APIError(0, f"cannot reach {self.base_url}: {getattr(e, 'reason', e)}") from None
|
|
181
|
+
raise APIError(0, "unreachable") # pragma: no cover
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _api_error(status: int, raw: str, request_id: str) -> APIError:
|
|
185
|
+
try:
|
|
186
|
+
body = json.loads(raw)
|
|
187
|
+
except ValueError:
|
|
188
|
+
return APIError(status, raw.strip()[:300] or "error", request_id=request_id)
|
|
189
|
+
err = body.get("error", body) if isinstance(body, dict) else body
|
|
190
|
+
if isinstance(err, dict):
|
|
191
|
+
return APIError(status, str(err.get("message") or err), str(err.get("code") or ""),
|
|
192
|
+
str(body.get("request_id") or request_id), body)
|
|
193
|
+
return APIError(status, str(err), request_id=request_id, body=body)
|
thx02/engine.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Request planning shared by the local runtime and the server: option-order averaging, the joint-read rule and
|
|
2
|
+
the pieces of self-gated thinking (gate question, think prompt, re-read with the thought)."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
|
|
8
|
+
from .protocol import DEFAULT_INSTRUCTIONS, describe, options
|
|
9
|
+
|
|
10
|
+
THINK_SYSTEM = "You reason briefly about decisions. Think in at most 60 words, only what decides the answer."
|
|
11
|
+
THOUGHT_NOTE = "\n\nReasoning notes (may contain mistakes): "
|
|
12
|
+
GATE_QUESTION = ("Would a careful person need to work this out step by step (calculation, logic puzzle, code tracing, "
|
|
13
|
+
"multi-hop reasoning) rather than just read, recognise or classify it?")
|
|
14
|
+
|
|
15
|
+
# thinking presets: "auto" = Decision Index setting (thinks only when unsure and the question needs reasoning),
|
|
16
|
+
# True = think on nearly every question
|
|
17
|
+
AUTO = {"tau": 0.6, "gate": 0.2, "alpha": 0.7, "cap": 96}
|
|
18
|
+
ALWAYS = {"tau": 0.97, "gate": 0.0, "alpha": 0.5, "cap": 320}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def orders(n: int, k: int) -> list[list[int]]:
|
|
22
|
+
"""Identity, reversed, then cyclic shifts of n options; the first k distinct orders."""
|
|
23
|
+
out = [list(range(n)), list(range(n))[::-1]] + [[(i + s) % n for i in range(n)] for s in range(1, n)]
|
|
24
|
+
seen, uniq = set(), []
|
|
25
|
+
for o in out:
|
|
26
|
+
if tuple(o) not in seen:
|
|
27
|
+
seen.add(tuple(o))
|
|
28
|
+
uniq.append(o)
|
|
29
|
+
return uniq[:k]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def facet_joint(questions: dict) -> bool:
|
|
33
|
+
"""Read jointly when several questions apply the same criteria to different items with plain-text instructions
|
|
34
|
+
(select-all candidates, aspect/sentiment pairs, per-entity labels); otherwise one question per request."""
|
|
35
|
+
v = list(questions.values())
|
|
36
|
+
return (len(v) > 1 and all(isinstance(q.get("instructions"), str) for q in v) and len({q.get("type") for q in v}) == 1
|
|
37
|
+
and len({json.dumps(q.get("criteria"), sort_keys=True) for q in v}) == 1)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def permuted(q: dict, order: list[int]) -> dict:
|
|
41
|
+
crit = q["criteria"]
|
|
42
|
+
keys, vals = list(crit), list(crit.values())
|
|
43
|
+
return {**q, "criteria": {keys[i]: vals[i] for i in order}}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def distribution(a: dict, keys: list[str]) -> list[float]:
|
|
47
|
+
if a["type"] == "noul":
|
|
48
|
+
return [a["noul"] if x == "true" else 1 - a["noul"] for x in keys]
|
|
49
|
+
return [a["probabilities"][x] for x in keys]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def geo_mean(ps: list[list[float]]) -> list[float]:
|
|
53
|
+
m = [math.exp(sum(math.log(max(p[i], 1e-9)) for p in ps) / len(ps)) for i in range(len(ps[0]))]
|
|
54
|
+
z = sum(m)
|
|
55
|
+
return [x / z for x in m]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def gate_request(state, q: dict) -> dict:
|
|
59
|
+
"""Cheap yes/no request: does question q need step-by-step reasoning? Uses a short state excerpt."""
|
|
60
|
+
_, desc = options(q)
|
|
61
|
+
text = ("Question: " + describe(q.get("instructions") or "Choose the best matching option.") + "\nOptions:\n" +
|
|
62
|
+
"\n".join(str(x)[:200] for x in desc[:12]) + "\n\nContext excerpt:\n" + describe(state)[:1200])
|
|
63
|
+
return {"state": text, "questions": {"g": {"type": "noul", "instructions": GATE_QUESTION}}}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def think_body(enc, request: dict) -> str:
|
|
67
|
+
"""User text of the think pass (the format the think adapter was trained on): state, one question, coded options."""
|
|
68
|
+
(key, q), = request["questions"].items()
|
|
69
|
+
_, desc = options(q)
|
|
70
|
+
return ("State:\n" + enc._text(request["state"]) + "\n\nQuestion:\n" + enc._text(q.get("instructions") or DEFAULT_INSTRUCTIONS)
|
|
71
|
+
+ "\nOptions:\n" + "\n".join(f"{c}: {enc._text(t)}" for c, t in zip(enc.codes, desc)))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def with_thought(q: dict, thought: str) -> dict:
|
|
75
|
+
q = dict(q)
|
|
76
|
+
q["instructions"] = describe(q.get("instructions") or "Choose the best matching option.") + THOUGHT_NOTE + thought.strip()
|
|
77
|
+
return q
|