selfjevai-sdk 0.4.3__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.
- selfjev/__init__.py +45 -0
- selfjev/cli.py +274 -0
- selfjev/client.py +335 -0
- selfjev/core/__init__.py +1 -0
- selfjev/core/answers.py +82 -0
- selfjev/core/options.py +26 -0
- selfjev/core/schemas.py +118 -0
- selfjev/data/__init__.py +131 -0
- selfjev/data/__main__.py +9 -0
- selfjev/data/catalog.py +112 -0
- selfjev/data/providers.py +214 -0
- selfjev/deploy/__init__.py +2 -0
- selfjev/deploy/aws.py +205 -0
- selfjev/engine/__init__.py +2 -0
- selfjev/engine/ollama.py +123 -0
- selfjev/engine/qwen35.py +220 -0
- selfjev/engine/tree.py +446 -0
- selfjev/engine/utils.py +14 -0
- selfjev/engine/vllm.py +140 -0
- selfjev/evaluation/__init__.py +1 -0
- selfjev/evaluation/benchmark.py +190 -0
- selfjev/evaluation/calibration.py +137 -0
- selfjev/evaluation/evaluate.py +447 -0
- selfjev/evaluation/stats.py +9 -0
- selfjev/py.typed +0 -0
- selfjev/server/__init__.py +1 -0
- selfjev/server/app.py +218 -0
- selfjev/server/batching.py +62 -0
- selfjev/server/compat.py +77 -0
- selfjev/server/finetuning.py +311 -0
- selfjev/server/metrics.py +32 -0
- selfjev/training/__init__.py +1 -0
- selfjev/training/batching.py +45 -0
- selfjev/training/finetune.py +239 -0
- selfjev/training/losses.py +27 -0
- selfjev/training/rlcd.py +78 -0
- selfjev/types.py +297 -0
- selfjevai_sdk-0.4.3.dist-info/METADATA +189 -0
- selfjevai_sdk-0.4.3.dist-info/RECORD +42 -0
- selfjevai_sdk-0.4.3.dist-info/WHEEL +4 -0
- selfjevai_sdk-0.4.3.dist-info/entry_points.txt +3 -0
- selfjevai_sdk-0.4.3.dist-info/licenses/LICENSE +202 -0
selfjev/__init__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""selfjev: an open decisions model (selfjev-4b) with Jev's API.
|
|
2
|
+
|
|
3
|
+
The SDK is importable without torch: `from selfjev import SelfJev, Noul, Choice, Score, Multi`. Serving, training and
|
|
4
|
+
evaluation live in selfjev.engine, selfjev.server, selfjev.training and selfjev.evaluation (extras: serve, train).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from importlib.metadata import version as _version
|
|
8
|
+
|
|
9
|
+
from .client import (
|
|
10
|
+
APIConnectionError,
|
|
11
|
+
APIError,
|
|
12
|
+
AsyncSelfJev,
|
|
13
|
+
AuthenticationError,
|
|
14
|
+
InvalidRequestError,
|
|
15
|
+
NotFoundError,
|
|
16
|
+
OverloadedError,
|
|
17
|
+
RateLimitError,
|
|
18
|
+
SelfJev,
|
|
19
|
+
SelfJevError,
|
|
20
|
+
)
|
|
21
|
+
from .types import Choice, DecisionRequest, DecisionResponse, FileObject, FineTuningJob, JobEvent, Multi, Noul, Score, TrainingRow
|
|
22
|
+
|
|
23
|
+
__version__ = _version("selfjevai-sdk") # one source: pyproject.toml
|
|
24
|
+
__all__ = [
|
|
25
|
+
"APIConnectionError",
|
|
26
|
+
"APIError",
|
|
27
|
+
"AsyncSelfJev",
|
|
28
|
+
"AuthenticationError",
|
|
29
|
+
"Choice",
|
|
30
|
+
"DecisionRequest",
|
|
31
|
+
"DecisionResponse",
|
|
32
|
+
"FileObject",
|
|
33
|
+
"FineTuningJob",
|
|
34
|
+
"InvalidRequestError",
|
|
35
|
+
"JobEvent",
|
|
36
|
+
"Multi",
|
|
37
|
+
"NotFoundError",
|
|
38
|
+
"Noul",
|
|
39
|
+
"OverloadedError",
|
|
40
|
+
"RateLimitError",
|
|
41
|
+
"Score",
|
|
42
|
+
"SelfJev",
|
|
43
|
+
"SelfJevError",
|
|
44
|
+
"TrainingRow",
|
|
45
|
+
]
|
selfjev/cli.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""selfjev: serve | classify | eval | calibrate | compare | bench | finetune | rlcd | merge | deploy"""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
DEFAULT_ADAPTER = "weights/selfjev_4b_vision"
|
|
11
|
+
HUB_ADAPTER = "Jwuthrich/selfjev-4b-vision" # what DEFAULT_ADAPTER holds, for a pip install with no checkout
|
|
12
|
+
ADAPTER_FILES = ["adapter_model.safetensors", "adapter_config.json", "model.json"]
|
|
13
|
+
# The first module a command imports from each extra, and the extra that provides it.
|
|
14
|
+
EXTRAS = dict.fromkeys(("fastapi", "uvicorn", "multipart", "torch", "transformers", "peft", "safetensors", "huggingface_hub"), "serve")
|
|
15
|
+
EXTRAS |= {"boto3": "deploy", "bitsandbytes": "quant", "accelerate": "quant"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _adapter(path):
|
|
19
|
+
"""A local adapter directory, or a Hugging Face repo id downloaded to the HF cache (DEFAULT_ADAPTER → HUB_ADAPTER)."""
|
|
20
|
+
if not path or Path(path).exists():
|
|
21
|
+
return path
|
|
22
|
+
repo = HUB_ADAPTER if path == DEFAULT_ADAPTER else path
|
|
23
|
+
if repo.count("/") != 1 or repo.startswith((".", "/", "~")):
|
|
24
|
+
raise SystemExit(f"adapter not found: {path}")
|
|
25
|
+
from huggingface_hub import snapshot_download
|
|
26
|
+
|
|
27
|
+
print(f"adapter {path} not found locally; downloading {repo} from Hugging Face", file=sys.stderr)
|
|
28
|
+
return snapshot_download(repo, allow_patterns=ADAPTER_FILES)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _scorer(a):
|
|
32
|
+
"""The model behind every command: the shared-prefix tree (transformers, exact) or vLLM on merged weights."""
|
|
33
|
+
if a.engine == "vllm":
|
|
34
|
+
from .engine.vllm import VllmScorer
|
|
35
|
+
|
|
36
|
+
return VllmScorer(a.model_dir, max_length=a.max_length)
|
|
37
|
+
if a.engine == "ollama":
|
|
38
|
+
from .engine.ollama import OllamaScorer
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
return OllamaScorer(a.ollama_model, a.ollama_host, max_length=a.max_length)
|
|
42
|
+
except RuntimeError as e:
|
|
43
|
+
raise SystemExit(f"{e}\nget the model: ollama pull hf.co/Jwuthrich/selfjev-4b-vision-GGUF:Q8_0") from None
|
|
44
|
+
from .engine.tree import TreeServer
|
|
45
|
+
|
|
46
|
+
return TreeServer(
|
|
47
|
+
a.adapter,
|
|
48
|
+
max_length=a.max_length,
|
|
49
|
+
max_batch_tokens=a.max_batch_tokens,
|
|
50
|
+
merge=not getattr(a, "fine_tuning", False),
|
|
51
|
+
quantize=a.quantize,
|
|
52
|
+
quantized_model=a.quantized_model,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _calibration(a, scorer):
|
|
57
|
+
if not a.calibration:
|
|
58
|
+
return None
|
|
59
|
+
from .evaluation import calibration
|
|
60
|
+
|
|
61
|
+
return calibration.load(a.calibration, scorer.meta, scorer.meta.get("prompt_sha"))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _model_args(p):
|
|
65
|
+
p.add_argument(
|
|
66
|
+
"--adapter", default=DEFAULT_ADAPTER, help=f"LoRA adapter dir or Hugging Face repo (default {DEFAULT_ADAPTER}, else {HUB_ADAPTER})"
|
|
67
|
+
)
|
|
68
|
+
p.add_argument(
|
|
69
|
+
"--engine",
|
|
70
|
+
default="tree",
|
|
71
|
+
choices=["tree", "vllm", "ollama"],
|
|
72
|
+
help="tree: exact, any number of questions; vllm: merged weights; ollama: a GGUF served by Ollama (no torch)",
|
|
73
|
+
)
|
|
74
|
+
p.add_argument("--model-dir", help="--engine vllm: merged checkpoint from `selfjev merge`")
|
|
75
|
+
p.add_argument("--ollama-model", default="selfjev-4b", help="--engine ollama: the model name in Ollama")
|
|
76
|
+
p.add_argument("--ollama-host", default="http://localhost:11434", help="--engine ollama: where Ollama listens")
|
|
77
|
+
p.add_argument("--max-length", type=int, default=32768, help="state + longest question, in tokens; longer input is an error")
|
|
78
|
+
p.add_argument("--max-batch-tokens", type=int, default=16384, help="packed tokens per forward pass")
|
|
79
|
+
p.add_argument("--quantize", choices=["8bit", "4bit"], help="bitsandbytes weights for an 8 GB GPU (extra: quant)")
|
|
80
|
+
p.add_argument(
|
|
81
|
+
"--quantized-model", help="a pre-quantized checkpoint (dir or Hugging Face repo, `selfjev quantize` writes one); replaces --adapter"
|
|
82
|
+
)
|
|
83
|
+
p.add_argument("--calibration", help="calibration JSON from `selfjev calibrate` (must match model/adapter/prompt)")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _finetune_args(p, rlcd=False):
|
|
87
|
+
p.add_argument("--data", required=True, help="training JSONL or .jsonl.gz (state, question, target per line)")
|
|
88
|
+
p.add_argument("--val", help="validation JSONL (default: 5%% of --data, at most 1,000 questions)")
|
|
89
|
+
p.add_argument("--out", required=True, help="run directory: adapter/, adapter_last/, train_meta.json")
|
|
90
|
+
p.add_argument("--init", required=rlcd, help=f"adapter to start from, e.g. {DEFAULT_ADAPTER}" + (" or a finetune run" if rlcd else ""))
|
|
91
|
+
p.add_argument("--no-options-in-question", action="store_true", help="do not list every option in the question text")
|
|
92
|
+
p.add_argument("--epochs", type=int, help="default: 1, or enough for 10 optimizer steps on small data (at most 10)")
|
|
93
|
+
p.add_argument("--lr", type=float, help="default 2e-4 (finetune from scratch), 5e-5 (from --init, rlcd)")
|
|
94
|
+
p.add_argument("--lora-r", type=int, default=64, help="LoRA rank when starting without --init")
|
|
95
|
+
p.add_argument("--max-length", type=int, default=16384, help="longer questions are dropped and counted, never truncated")
|
|
96
|
+
p.add_argument("--batch-tokens", type=int, default=16384, help="packed tree tokens per micro-batch (at least --max-length)")
|
|
97
|
+
p.add_argument("--grad-accum", type=int, default=2)
|
|
98
|
+
p.add_argument("--eval-every", type=int, default=150)
|
|
99
|
+
p.add_argument("--seed", type=int, default=13)
|
|
100
|
+
p.add_argument(
|
|
101
|
+
"--soft-weight", type=float, default=0.5, help='rows with "soft" (a teacher\'s probabilities) train on (1 - w) x label + w x soft'
|
|
102
|
+
)
|
|
103
|
+
if rlcd:
|
|
104
|
+
p.add_argument(
|
|
105
|
+
"--reward", default="log=1,brier=1,spherical=1", help="weights of log, brier, spherical (proper), accuracy, confident_miss"
|
|
106
|
+
)
|
|
107
|
+
p.add_argument("--samples", type=int, default=8, help="sampled reports per question")
|
|
108
|
+
p.add_argument("--sigma", type=float, default=0.3, help="sd of the Gaussian around the logits")
|
|
109
|
+
p.add_argument("--beta", type=float, default=0.05, help="KL penalty to the --init model")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def main(argv=None):
|
|
113
|
+
ap = argparse.ArgumentParser(
|
|
114
|
+
prog="selfjev", description="Serve, evaluate and fine-tune selfjev-4b, an open decisions model with Jev's API."
|
|
115
|
+
)
|
|
116
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
117
|
+
|
|
118
|
+
p = sub.add_parser("serve", help="HTTP server with Jev's decisions API (POST /api/alpha/decisions) and POST /classify")
|
|
119
|
+
_model_args(p)
|
|
120
|
+
p.add_argument("--host", default="127.0.0.1")
|
|
121
|
+
p.add_argument("--port", type=int, default=8000)
|
|
122
|
+
p.add_argument("--no-options-in-question", action="store_true", help="for adapters trained without the option list")
|
|
123
|
+
p.add_argument(
|
|
124
|
+
"--fine-tuning",
|
|
125
|
+
action="store_true",
|
|
126
|
+
help="also serve /v1/files and /v1/fine_tuning/jobs (LoRA unmerged; training needs GPU memory next to serving, e.g. a 48 GB card)",
|
|
127
|
+
)
|
|
128
|
+
p.add_argument("--home", default=os.environ.get("SELFJEV_HOME", str(Path.home() / ".selfjev" / "server")), help="fine-tuning state")
|
|
129
|
+
p = sub.add_parser("classify", help="answer one request in the internal schema (a JSON file, or - for stdin)")
|
|
130
|
+
p.add_argument("request")
|
|
131
|
+
p.add_argument("--no-options-in-question", action="store_true", help="for adapters trained without the option list")
|
|
132
|
+
_model_args(p)
|
|
133
|
+
p = sub.add_parser("eval", help="evaluate JSONL examples; writes report.json and report.md")
|
|
134
|
+
p.add_argument("--data", nargs="+", required=True)
|
|
135
|
+
p.add_argument("--split", nargs="*", help="only these splits (e.g. test); default all")
|
|
136
|
+
p.add_argument("--out", required=True)
|
|
137
|
+
p.add_argument("--limit", type=int)
|
|
138
|
+
_model_args(p)
|
|
139
|
+
p = sub.add_parser("calibrate", help="fit temperatures on a calibration-split report; thresholds on a validation-split report")
|
|
140
|
+
p.add_argument("--fit", required=True, help="report.json from `selfjev eval --split calibration`")
|
|
141
|
+
p.add_argument("--thresholds", help="report.json from `selfjev eval --split validation`")
|
|
142
|
+
p.add_argument("--out", required=True)
|
|
143
|
+
p = sub.add_parser("compare", help="side-by-side markdown of two or more eval reports")
|
|
144
|
+
p.add_argument("reports", nargs="+")
|
|
145
|
+
p.add_argument("--names", nargs="*")
|
|
146
|
+
p.add_argument("--out")
|
|
147
|
+
p = sub.add_parser("bench", help="latency / throughput / memory on synthetic requests")
|
|
148
|
+
_model_args(p)
|
|
149
|
+
p.add_argument("--lengths", default="512,2048,8192")
|
|
150
|
+
p.add_argument("--questions", default="1,4,16")
|
|
151
|
+
p.add_argument("--repeats", type=int, default=20, help="max samples per row (at least 3; ~60 s budget per row)")
|
|
152
|
+
p.add_argument("--out", required=True)
|
|
153
|
+
_finetune_args(sub.add_parser("finetune", help="LoRA fine-tune selfjev's recipe (Qwen3.5 + shared-prefix tree) on your data"))
|
|
154
|
+
_finetune_args(sub.add_parser("rlcd", help="RLCD (proper-scoring-rule training) from a fine-tuned adapter"), rlcd=True)
|
|
155
|
+
p = sub.add_parser("quantize", help="write a 4-bit or 8-bit checkpoint of an adapter merged into Qwen3.5-4B (needs a CUDA GPU)")
|
|
156
|
+
p.add_argument("--adapter", default=DEFAULT_ADAPTER)
|
|
157
|
+
p.add_argument("--bits", required=True, choices=["8bit", "4bit"])
|
|
158
|
+
p.add_argument("--out", required=True)
|
|
159
|
+
p = sub.add_parser("merge", help="merge a LoRA adapter into Qwen3.5-4B for --engine vllm")
|
|
160
|
+
p.add_argument("--adapter", default=DEFAULT_ADAPTER)
|
|
161
|
+
p.add_argument("--out", required=True)
|
|
162
|
+
p = sub.add_parser("deploy", help="deploy a server: `selfjev deploy aws up|down|status|list|machines`")
|
|
163
|
+
dsub = p.add_subparsers(dest="provider", required=True)
|
|
164
|
+
aws = dsub.add_parser("aws", help="an EC2 GPU box running `selfjev serve` (pip install selfjevai-sdk[deploy])")
|
|
165
|
+
aws.add_argument("action", choices=["up", "down", "status", "list", "machines"])
|
|
166
|
+
aws.add_argument("--name", default="selfjev", help="deployment name (tags, state file)")
|
|
167
|
+
aws.add_argument("--instance", default="g6.xlarge", help="EC2 type; see `selfjev deploy aws machines`")
|
|
168
|
+
aws.add_argument("--region", default="us-east-2")
|
|
169
|
+
aws.add_argument("--allow-cidr", default="0.0.0.0/0", help="who may reach the API port (the API key still applies)")
|
|
170
|
+
aws.add_argument("--ref", default="master", help="git ref of this repository to deploy")
|
|
171
|
+
aws.add_argument("--api-key", help="default: a new random key, printed once and kept in the state file")
|
|
172
|
+
aws.add_argument("--max-hours", type=float, help="terminate the box after this many hours (a cost cap for trials)")
|
|
173
|
+
aws.add_argument("--ssh", action="store_true", help="also a key pair and port 22 from this machine, for debugging")
|
|
174
|
+
aws.add_argument("--no-wait", action="store_true", help="return once the instance runs, before the server is ready")
|
|
175
|
+
aws.add_argument("--fine-tuning", action="store_true", help="also serve the fine-tuning routes (jobs train on the box: 48 GB GPU)")
|
|
176
|
+
a = ap.parse_args(argv)
|
|
177
|
+
try:
|
|
178
|
+
for k in ("adapter", "init"):
|
|
179
|
+
own = getattr(a, "engine", "tree") in ("vllm", "ollama") or getattr(
|
|
180
|
+
a, "quantized_model", None
|
|
181
|
+
) # these take their own model flags
|
|
182
|
+
if getattr(a, k, None) and not (k == "adapter" and own):
|
|
183
|
+
setattr(a, k, _adapter(getattr(a, k)))
|
|
184
|
+
_run(a)
|
|
185
|
+
except ModuleNotFoundError as e:
|
|
186
|
+
root = (e.name or "").split(".")[0]
|
|
187
|
+
if root == "vllm":
|
|
188
|
+
raise SystemExit("--engine vllm needs vLLM: pip install vllm") from e
|
|
189
|
+
extra = "train" if a.cmd in ("finetune", "rlcd") else EXTRAS.get(root)
|
|
190
|
+
if not extra:
|
|
191
|
+
raise
|
|
192
|
+
raise SystemExit(f"selfjev {a.cmd} needs the {extra} extra ({root} is missing): pip install 'selfjevai-sdk[{extra}]'") from e
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _run(a):
|
|
196
|
+
if a.cmd == "serve":
|
|
197
|
+
from .server.app import serve
|
|
198
|
+
|
|
199
|
+
scorer = _scorer(a)
|
|
200
|
+
home = Path(a.home) if a.fine_tuning else None
|
|
201
|
+
serve(scorer, a.host, a.port, _calibration(a, scorer), not a.no_options_in_question, fine_tuning_home=home, init_adapter=a.adapter)
|
|
202
|
+
elif a.cmd == "classify":
|
|
203
|
+
from .core.answers import classify
|
|
204
|
+
from .core.options import with_option_lists
|
|
205
|
+
|
|
206
|
+
scorer = _scorer(a)
|
|
207
|
+
req = json.load(sys.stdin) if a.request == "-" else json.loads(Path(a.request).read_text())
|
|
208
|
+
req = req if a.no_options_in_question else with_option_lists(req) # as selfjev-4b was trained
|
|
209
|
+
print(json.dumps(classify(scorer, req, _calibration(a, scorer)), indent=2))
|
|
210
|
+
elif a.cmd == "eval":
|
|
211
|
+
from .evaluation.evaluate import run
|
|
212
|
+
|
|
213
|
+
scorer = _scorer(a)
|
|
214
|
+
rep = run(scorer, a.data, a.split, _calibration(a, scorer), a.out, a.limit)
|
|
215
|
+
print((Path(a.out) / "report.md").read_text())
|
|
216
|
+
print(f"n={rep['meta']['n']} -> {a.out}/report.json")
|
|
217
|
+
elif a.cmd == "calibrate":
|
|
218
|
+
from .evaluation.calibration import fit
|
|
219
|
+
|
|
220
|
+
print(json.dumps(fit(a.fit, a.thresholds, a.out), indent=2))
|
|
221
|
+
elif a.cmd == "compare":
|
|
222
|
+
from .evaluation.evaluate import compare
|
|
223
|
+
|
|
224
|
+
md = compare([json.loads(Path(r).read_text()) for r in a.reports], a.names or [Path(r).parent.name for r in a.reports])
|
|
225
|
+
if a.out:
|
|
226
|
+
Path(a.out).write_text(md)
|
|
227
|
+
print(md)
|
|
228
|
+
elif a.cmd == "bench":
|
|
229
|
+
from .evaluation.benchmark import default_grid, run
|
|
230
|
+
|
|
231
|
+
t0 = time.perf_counter()
|
|
232
|
+
scorer = _scorer(a)
|
|
233
|
+
grid = default_grid(tuple(map(int, a.lengths.split(","))), tuple(map(int, a.questions.split(","))))
|
|
234
|
+
run(scorer, grid, a.repeats, out_dir=a.out, load_ms=1e3 * (time.perf_counter() - t0))
|
|
235
|
+
print((Path(a.out) / "bench.md").read_text())
|
|
236
|
+
elif a.cmd in ("finetune", "rlcd"):
|
|
237
|
+
from .training.finetune import train
|
|
238
|
+
|
|
239
|
+
extra = {}
|
|
240
|
+
if a.cmd == "rlcd":
|
|
241
|
+
extra = {"reward_weights": {k: float(v) for k, v in (x.split("=") for x in a.reward.split(","))}}
|
|
242
|
+
extra |= {"samples": a.samples, "sigma": a.sigma, "beta": a.beta}
|
|
243
|
+
train(a.cmd, a.data, a.out, val=a.val, init=a.init, options_in_question=not a.no_options_in_question, epochs=a.epochs, lr=a.lr,
|
|
244
|
+
lora_r=a.lora_r, max_length=a.max_length, batch_tokens=a.batch_tokens, grad_accum=a.grad_accum, eval_every=a.eval_every,
|
|
245
|
+
seed=a.seed, soft_weight=a.soft_weight, **extra) # fmt: skip
|
|
246
|
+
elif a.cmd == "deploy":
|
|
247
|
+
from .deploy import aws
|
|
248
|
+
|
|
249
|
+
if a.action == "machines":
|
|
250
|
+
for k, (gpu, price, use) in aws.MACHINES.items():
|
|
251
|
+
print(f"{k:13s} {gpu:22s} ${price:.3f}/h {use}")
|
|
252
|
+
elif a.action == "up":
|
|
253
|
+
rec = aws.up(a.name, a.instance, a.region, a.allow_cidr, a.ref, a.api_key, a.max_hours, a.ssh, not a.no_wait, a.fine_tuning)
|
|
254
|
+
print(json.dumps({k: rec[k] for k in rec if k != "security_group"}, indent=2))
|
|
255
|
+
elif a.action == "list":
|
|
256
|
+
print(json.dumps([{k: r[k] for k in ("name", "region", "instance_type", "endpoint")} for r in aws.listing()], indent=2))
|
|
257
|
+
else:
|
|
258
|
+
print(json.dumps(getattr(aws, a.action)(a.name), indent=2, default=str))
|
|
259
|
+
elif a.cmd == "quantize":
|
|
260
|
+
from .engine.qwen35 import Qwen35Scorer
|
|
261
|
+
|
|
262
|
+
sc = Qwen35Scorer(adapter=a.adapter, quantize=a.bits)
|
|
263
|
+
sc.model.save_pretrained(a.out)
|
|
264
|
+
sc.tokenizer.save_pretrained(a.out)
|
|
265
|
+
print(f"{a.bits} checkpoint -> {a.out}")
|
|
266
|
+
elif a.cmd == "merge":
|
|
267
|
+
from .engine.vllm import merge
|
|
268
|
+
|
|
269
|
+
merge(a.adapter, a.out)
|
|
270
|
+
print(f"merged -> {a.out}")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
if __name__ == "__main__":
|
|
274
|
+
main()
|
selfjev/client.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""The selfjev SDK: a client for the decisions API, in Jev's shape (docs/api.md). Works against a selfjev server and
|
|
2
|
+
against Jev itself (TypeSafe's /v1/systemone, or OpenRouter with path="/api/alpha/decisions").
|
|
3
|
+
|
|
4
|
+
from selfjev import SelfJev, Choice, Noul
|
|
5
|
+
|
|
6
|
+
client = SelfJev(base_url="http://localhost:8000") # or SELFJEV_BASE_URL / SELFJEV_API_KEY
|
|
7
|
+
res = client.system_one(
|
|
8
|
+
state="Ticket 4411: the invoice was charged twice.",
|
|
9
|
+
questions={"refund": Noul("Does the customer ask for a refund?"),
|
|
10
|
+
"team": Choice("Which team?", {"billing": "billing and refunds", "tech": "outages and bugs"})},
|
|
11
|
+
)
|
|
12
|
+
res.choices["team"].choice, res.nouls["refund"].noul
|
|
13
|
+
|
|
14
|
+
Images (the default tree engine): `state=[Path("cat.jpg")]`, or a PIL image or image bytes, alone or next to text parts.
|
|
15
|
+
|
|
16
|
+
Fine-tuning (a selfjev server started with --fine-tuning; docs/api.md "Fine-tuning"):
|
|
17
|
+
|
|
18
|
+
f = client.upload_file("train.jsonl") # one decisions request + its answers per line
|
|
19
|
+
f = client.upload_file([{"state": [Path("a.jpg")], "questions": {...}, "answers": {...}}, ...]) # or rows: images inline
|
|
20
|
+
job = client.create_fine_tuning_job(f.id, method="rlcd", suffix="acme")
|
|
21
|
+
job = client.wait_fine_tuning_job(job.id) # polls until it ends; then system_one(..., model=job.fine_tuned_model)
|
|
22
|
+
|
|
23
|
+
Needs only httpx and pydantic. Retries 429, 529, 5xx and connection errors with exponential backoff (Retry-After wins).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import asyncio
|
|
27
|
+
import base64
|
|
28
|
+
import io
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import random
|
|
32
|
+
import time
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any, Literal
|
|
35
|
+
|
|
36
|
+
import httpx
|
|
37
|
+
from pydantic import TypeAdapter
|
|
38
|
+
|
|
39
|
+
from .types import (
|
|
40
|
+
DecisionRequest,
|
|
41
|
+
DecisionResponse,
|
|
42
|
+
ErrorResponse,
|
|
43
|
+
FileObject,
|
|
44
|
+
FineTuningJob,
|
|
45
|
+
FineTuningJobRequest,
|
|
46
|
+
JobEvent,
|
|
47
|
+
Question,
|
|
48
|
+
TrainingRow,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
DEFAULT_BASE_URL = "http://localhost:8000"
|
|
52
|
+
DEFAULT_PATH = "/v1/systemone"
|
|
53
|
+
RETRY_STATUSES = frozenset({429, 500, 502, 503, 504, 529})
|
|
54
|
+
_QUESTIONS = TypeAdapter(dict[str, Question])
|
|
55
|
+
_JOBS, _EVENTS = TypeAdapter(list[FineTuningJob]), TypeAdapter(list[JobEvent])
|
|
56
|
+
JOBS = "/v1/fine_tuning/jobs"
|
|
57
|
+
DONE = frozenset({"succeeded", "failed", "cancelled"})
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class SelfJevError(Exception):
|
|
61
|
+
"""Base class of every SDK error."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class APIConnectionError(SelfJevError):
|
|
65
|
+
"""The server could not be reached (after retries)."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class APIError(SelfJevError):
|
|
69
|
+
"""The server answered with an error status. `type` and `message` come from its JSON error body."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, status: int, message: str, type: str = "api_error", param: str | None = None, request_id: str | None = None):
|
|
72
|
+
super().__init__(f"{status} {type}: {message}")
|
|
73
|
+
self.status, self.message, self.type, self.param, self.request_id = status, message, type, param, request_id
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class AuthenticationError(APIError):
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class NotFoundError(APIError):
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class InvalidRequestError(APIError):
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class RateLimitError(APIError):
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class OverloadedError(APIError):
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
_BY_STATUS = {401: AuthenticationError, 403: AuthenticationError, 404: NotFoundError, 400: InvalidRequestError,
|
|
97
|
+
422: InvalidRequestError, 429: RateLimitError, 529: OverloadedError} # fmt: skip
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _error(r: httpx.Response) -> APIError:
|
|
101
|
+
try:
|
|
102
|
+
e = ErrorResponse.model_validate(r.json()).error
|
|
103
|
+
message, kind, param = e.message, e.type, e.param
|
|
104
|
+
except Exception: # not our JSON error body: keep the raw text
|
|
105
|
+
message, kind, param = r.text[:500] or r.reason_phrase, "api_error", None
|
|
106
|
+
return _BY_STATUS.get(r.status_code, APIError)(r.status_code, message, kind, param, r.headers.get("x-request-id"))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _delay(attempt: int, r: httpx.Response | None) -> float:
|
|
110
|
+
after = r.headers.get("retry-after") if r is not None else None
|
|
111
|
+
if after:
|
|
112
|
+
try:
|
|
113
|
+
return min(float(after), 60.0)
|
|
114
|
+
except ValueError:
|
|
115
|
+
pass
|
|
116
|
+
return min(8.0, 0.5 * 2**attempt) * (0.5 + random.random() / 2)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
_MAGIC = {b"\x89PNG": "png", b"\xff\xd8\xff": "jpeg", b"GIF8": "gif", b"RIFF": "webp"}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _part(p):
|
|
123
|
+
"""An image in `state` (a PIL image, image file bytes, or a Path to an image file) -> a base64 data URL."""
|
|
124
|
+
if isinstance(p, Path):
|
|
125
|
+
p = p.read_bytes()
|
|
126
|
+
if hasattr(p, "save"): # PIL.Image
|
|
127
|
+
buf = io.BytesIO()
|
|
128
|
+
p.save(buf, format="PNG")
|
|
129
|
+
p = buf.getvalue()
|
|
130
|
+
if not isinstance(p, bytes):
|
|
131
|
+
return p
|
|
132
|
+
kind = next((k for m, k in _MAGIC.items() if p.startswith(m)), None)
|
|
133
|
+
if kind is None:
|
|
134
|
+
raise ValueError("state: bytes must be a PNG, JPEG, GIF or WebP image")
|
|
135
|
+
return f"data:image/{kind};base64," + base64.b64encode(p).decode()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _state(state):
|
|
139
|
+
"""Images in `state` (alone or among the parts of a list) -> data URLs; text and JSON stay as they are."""
|
|
140
|
+
return [_part(p) for p in state] if isinstance(state, list | tuple) else _part(state)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class _Base:
|
|
144
|
+
def __init__(self, api_key=None, base_url=None, model=None, timeout=60.0, max_retries=2, path=DEFAULT_PATH):
|
|
145
|
+
self.api_key = api_key if api_key is not None else os.environ.get("SELFJEV_API_KEY")
|
|
146
|
+
self.base_url = (base_url or os.environ.get("SELFJEV_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
147
|
+
self.model = model or os.environ.get("SELFJEV_DEFAULT_MODEL") or "selfjev-4b"
|
|
148
|
+
self.timeout, self.max_retries, self.path = timeout, max_retries, path
|
|
149
|
+
|
|
150
|
+
def _headers(self) -> dict[str, str]:
|
|
151
|
+
h = {"User-Agent": "selfjev-python"} # httpx sets Content-Type: JSON bodies and multipart uploads differ
|
|
152
|
+
if self.api_key:
|
|
153
|
+
h["Authorization"] = f"Bearer {self.api_key}"
|
|
154
|
+
return h
|
|
155
|
+
|
|
156
|
+
def _body(self, state, questions, model, extra_body) -> dict:
|
|
157
|
+
req = DecisionRequest(model=model or self.model, state=_state(state), questions=_QUESTIONS.validate_python(questions))
|
|
158
|
+
return req.model_dump(mode="json", exclude_none=True) | (extra_body or {})
|
|
159
|
+
|
|
160
|
+
@staticmethod
|
|
161
|
+
def _upload(file, purpose) -> dict: # bytes, not a file handle, so a retry resends the whole file
|
|
162
|
+
if isinstance(file, str | Path):
|
|
163
|
+
name, content = Path(file).name, Path(file).read_bytes()
|
|
164
|
+
else: # rows, each checked here (a bad one fails before the upload); images as in system_one's state
|
|
165
|
+
rows = (r.model_dump(exclude_none=True) if isinstance(r, TrainingRow) else r for r in file)
|
|
166
|
+
rows = [TrainingRow.model_validate(r | {"state": _state(r["state"])}).model_dump(mode="json", exclude_none=True) for r in rows]
|
|
167
|
+
name, content = "rows.jsonl", "".join(json.dumps(r, ensure_ascii=False) + "\n" for r in rows).encode()
|
|
168
|
+
return {"files": {"file": (name, content)}, "data": {"purpose": purpose}}
|
|
169
|
+
|
|
170
|
+
@staticmethod
|
|
171
|
+
def _job(training_file, method, hyperparameters, validation_file, suffix, model) -> dict:
|
|
172
|
+
req = FineTuningJobRequest.model_validate(
|
|
173
|
+
{"training_file": training_file, "validation_file": validation_file, "suffix": suffix,
|
|
174
|
+
"method": {"type": method, "hyperparameters": hyperparameters or {}}} | ({"model": model} if model else {})
|
|
175
|
+
) # fmt: skip
|
|
176
|
+
return {"json": req.model_dump(mode="json", exclude_none=True)}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class SelfJev(_Base):
|
|
180
|
+
"""Synchronous client. Reuse one instance: it keeps a connection pool."""
|
|
181
|
+
|
|
182
|
+
def __init__(self, api_key=None, base_url=None, model=None, timeout=60.0, max_retries=2, path=DEFAULT_PATH, http_client=None):
|
|
183
|
+
super().__init__(api_key, base_url, model, timeout, max_retries, path)
|
|
184
|
+
self._http = http_client or httpx.Client(timeout=timeout)
|
|
185
|
+
|
|
186
|
+
def system_one(self, state, questions: dict[str, Any], model: str | None = None, extra_body: dict | None = None) -> DecisionResponse:
|
|
187
|
+
"""Answer every question about `state`. Questions: Noul / Choice / Score / Multi objects or plain dicts."""
|
|
188
|
+
return DecisionResponse.model_validate(self._post(self.path, self._body(state, questions, model, extra_body)))
|
|
189
|
+
|
|
190
|
+
def models(self) -> list[dict]:
|
|
191
|
+
return self._request("GET", "/v1/models").json()["data"]
|
|
192
|
+
|
|
193
|
+
def upload_file(self, file: str | Path | list, purpose: str = "fine-tune") -> FileObject:
|
|
194
|
+
"""Upload fine-tuning data: a JSONL file (one {"state", "questions", "answers"} object per line) or a list of such
|
|
195
|
+
rows (dicts or TrainingRow), whose images may be Paths, image bytes or PIL images as in system_one."""
|
|
196
|
+
return FileObject.model_validate(self._request("POST", "/v1/files", **self._upload(file, purpose)).json())
|
|
197
|
+
|
|
198
|
+
def create_fine_tuning_job(
|
|
199
|
+
self,
|
|
200
|
+
training_file: str,
|
|
201
|
+
method: Literal["supervised", "rlcd"] = "supervised",
|
|
202
|
+
hyperparameters: dict | None = None,
|
|
203
|
+
validation_file: str | None = None,
|
|
204
|
+
suffix: str | None = None,
|
|
205
|
+
model: str | None = None,
|
|
206
|
+
) -> FineTuningJob:
|
|
207
|
+
"""Queue a fine-tune (supervised) or RLCD job; its model is served under job.fine_tuned_model once it succeeds."""
|
|
208
|
+
body = self._job(training_file, method, hyperparameters, validation_file, suffix, model)
|
|
209
|
+
return FineTuningJob.model_validate(self._request("POST", JOBS, **body).json())
|
|
210
|
+
|
|
211
|
+
def fine_tuning_job(self, job_id: str) -> FineTuningJob:
|
|
212
|
+
return FineTuningJob.model_validate(self._request("GET", f"{JOBS}/{job_id}").json())
|
|
213
|
+
|
|
214
|
+
def fine_tuning_jobs(self) -> list[FineTuningJob]:
|
|
215
|
+
return _JOBS.validate_python(self._request("GET", JOBS).json()["data"])
|
|
216
|
+
|
|
217
|
+
def fine_tuning_events(self, job_id: str) -> list[JobEvent]:
|
|
218
|
+
return _EVENTS.validate_python(self._request("GET", f"{JOBS}/{job_id}/events").json()["data"])
|
|
219
|
+
|
|
220
|
+
def cancel_fine_tuning_job(self, job_id: str) -> FineTuningJob:
|
|
221
|
+
return FineTuningJob.model_validate(self._request("POST", f"{JOBS}/{job_id}/cancel").json())
|
|
222
|
+
|
|
223
|
+
def wait_fine_tuning_job(self, job_id: str, poll: float = 10.0, timeout: float | None = None) -> FineTuningJob:
|
|
224
|
+
"""Poll until the job succeeds, fails or is cancelled (check job.status); TimeoutError after `timeout` seconds."""
|
|
225
|
+
t0 = time.monotonic()
|
|
226
|
+
while (job := self.fine_tuning_job(job_id)).status not in DONE:
|
|
227
|
+
if timeout is not None and time.monotonic() - t0 > timeout:
|
|
228
|
+
raise TimeoutError(f"{job_id} still {job.status} after {timeout:.0f} s")
|
|
229
|
+
time.sleep(poll)
|
|
230
|
+
return job
|
|
231
|
+
|
|
232
|
+
def _post(self, path: str, body: dict) -> dict:
|
|
233
|
+
return self._request("POST", path, json=body).json()
|
|
234
|
+
|
|
235
|
+
def _request(self, method: str, path: str, **kw) -> httpx.Response:
|
|
236
|
+
for attempt in range(self.max_retries + 1):
|
|
237
|
+
try:
|
|
238
|
+
r = self._http.request(method, self.base_url + path, headers=self._headers(), **kw)
|
|
239
|
+
except httpx.TransportError as e:
|
|
240
|
+
if attempt == self.max_retries:
|
|
241
|
+
raise APIConnectionError(f"{method} {self.base_url + path}: {e}") from e
|
|
242
|
+
time.sleep(_delay(attempt, None))
|
|
243
|
+
continue
|
|
244
|
+
if r.status_code < 400:
|
|
245
|
+
return r
|
|
246
|
+
if r.status_code not in RETRY_STATUSES or attempt == self.max_retries:
|
|
247
|
+
raise _error(r)
|
|
248
|
+
time.sleep(_delay(attempt, r))
|
|
249
|
+
raise AssertionError("unreachable")
|
|
250
|
+
|
|
251
|
+
def close(self):
|
|
252
|
+
self._http.close()
|
|
253
|
+
|
|
254
|
+
def __enter__(self):
|
|
255
|
+
return self
|
|
256
|
+
|
|
257
|
+
def __exit__(self, *exc):
|
|
258
|
+
self.close()
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class AsyncSelfJev(_Base):
|
|
262
|
+
"""Asynchronous client: `await client.system_one(...)`."""
|
|
263
|
+
|
|
264
|
+
def __init__(self, api_key=None, base_url=None, model=None, timeout=60.0, max_retries=2, path=DEFAULT_PATH, http_client=None):
|
|
265
|
+
super().__init__(api_key, base_url, model, timeout, max_retries, path)
|
|
266
|
+
self._http = http_client or httpx.AsyncClient(timeout=timeout)
|
|
267
|
+
|
|
268
|
+
async def system_one(
|
|
269
|
+
self, state, questions: dict[str, Any], model: str | None = None, extra_body: dict | None = None
|
|
270
|
+
) -> DecisionResponse:
|
|
271
|
+
body = self._body(state, questions, model, extra_body)
|
|
272
|
+
return DecisionResponse.model_validate((await self._request("POST", self.path, json=body)).json())
|
|
273
|
+
|
|
274
|
+
async def models(self) -> list[dict]:
|
|
275
|
+
return (await self._request("GET", "/v1/models")).json()["data"]
|
|
276
|
+
|
|
277
|
+
async def upload_file(self, file: str | Path | list, purpose: str = "fine-tune") -> FileObject:
|
|
278
|
+
return FileObject.model_validate((await self._request("POST", "/v1/files", **self._upload(file, purpose))).json())
|
|
279
|
+
|
|
280
|
+
async def create_fine_tuning_job(
|
|
281
|
+
self,
|
|
282
|
+
training_file: str,
|
|
283
|
+
method: Literal["supervised", "rlcd"] = "supervised",
|
|
284
|
+
hyperparameters: dict | None = None,
|
|
285
|
+
validation_file: str | None = None,
|
|
286
|
+
suffix: str | None = None,
|
|
287
|
+
model: str | None = None,
|
|
288
|
+
) -> FineTuningJob:
|
|
289
|
+
body = self._job(training_file, method, hyperparameters, validation_file, suffix, model)
|
|
290
|
+
return FineTuningJob.model_validate((await self._request("POST", JOBS, **body)).json())
|
|
291
|
+
|
|
292
|
+
async def fine_tuning_job(self, job_id: str) -> FineTuningJob:
|
|
293
|
+
return FineTuningJob.model_validate((await self._request("GET", f"{JOBS}/{job_id}")).json())
|
|
294
|
+
|
|
295
|
+
async def fine_tuning_jobs(self) -> list[FineTuningJob]:
|
|
296
|
+
return _JOBS.validate_python((await self._request("GET", JOBS)).json()["data"])
|
|
297
|
+
|
|
298
|
+
async def fine_tuning_events(self, job_id: str) -> list[JobEvent]:
|
|
299
|
+
return _EVENTS.validate_python((await self._request("GET", f"{JOBS}/{job_id}/events")).json()["data"])
|
|
300
|
+
|
|
301
|
+
async def cancel_fine_tuning_job(self, job_id: str) -> FineTuningJob:
|
|
302
|
+
return FineTuningJob.model_validate((await self._request("POST", f"{JOBS}/{job_id}/cancel")).json())
|
|
303
|
+
|
|
304
|
+
async def wait_fine_tuning_job(self, job_id: str, poll: float = 10.0, timeout: float | None = None) -> FineTuningJob:
|
|
305
|
+
t0 = time.monotonic()
|
|
306
|
+
while (job := await self.fine_tuning_job(job_id)).status not in DONE:
|
|
307
|
+
if timeout is not None and time.monotonic() - t0 > timeout:
|
|
308
|
+
raise TimeoutError(f"{job_id} still {job.status} after {timeout:.0f} s")
|
|
309
|
+
await asyncio.sleep(poll)
|
|
310
|
+
return job
|
|
311
|
+
|
|
312
|
+
async def _request(self, method: str, path: str, **kw) -> httpx.Response:
|
|
313
|
+
for attempt in range(self.max_retries + 1):
|
|
314
|
+
try:
|
|
315
|
+
r = await self._http.request(method, self.base_url + path, headers=self._headers(), **kw)
|
|
316
|
+
except httpx.TransportError as e:
|
|
317
|
+
if attempt == self.max_retries:
|
|
318
|
+
raise APIConnectionError(f"{method} {self.base_url + path}: {e}") from e
|
|
319
|
+
await asyncio.sleep(_delay(attempt, None))
|
|
320
|
+
continue
|
|
321
|
+
if r.status_code < 400:
|
|
322
|
+
return r
|
|
323
|
+
if r.status_code not in RETRY_STATUSES or attempt == self.max_retries:
|
|
324
|
+
raise _error(r)
|
|
325
|
+
await asyncio.sleep(_delay(attempt, r))
|
|
326
|
+
raise AssertionError("unreachable")
|
|
327
|
+
|
|
328
|
+
async def aclose(self):
|
|
329
|
+
await self._http.aclose()
|
|
330
|
+
|
|
331
|
+
async def __aenter__(self):
|
|
332
|
+
return self
|
|
333
|
+
|
|
334
|
+
async def __aexit__(self, *exc):
|
|
335
|
+
await self.aclose()
|
selfjev/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Model-agnostic pieces: the internal request schema, the option-list transform, scores -> typed answers."""
|