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 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
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
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