tunekeep 0.2.1__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.
- tunekeep/__init__.py +3 -0
- tunekeep/__main__.py +3 -0
- tunekeep/cli.py +301 -0
- tunekeep/client.py +189 -0
- tunekeep/compare.py +276 -0
- tunekeep/files.py +42 -0
- tunekeep/inventory.py +173 -0
- tunekeep/kit/README.md +47 -0
- tunekeep/kit/modal_app.py +98 -0
- tunekeep/kit/requirements-gguf.txt +845 -0
- tunekeep/kit/requirements-train.txt +830 -0
- tunekeep/kit/train_lora.py +282 -0
- tunekeep/record.py +150 -0
- tunekeep/report.py +259 -0
- tunekeep/retrain.py +262 -0
- tunekeep/runner.py +91 -0
- tunekeep/schedule.py +74 -0
- tunekeep/score.py +166 -0
- tunekeep-0.2.1.dist-info/METADATA +185 -0
- tunekeep-0.2.1.dist-info/RECORD +23 -0
- tunekeep-0.2.1.dist-info/WHEEL +4 -0
- tunekeep-0.2.1.dist-info/entry_points.txt +3 -0
- tunekeep-0.2.1.dist-info/licenses/LICENSE +202 -0
tunekeep/__init__.py
ADDED
tunekeep/__main__.py
ADDED
tunekeep/cli.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""Command line entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .client import APIError, Client
|
|
13
|
+
from .files import ensure_private_dir, write_private
|
|
14
|
+
|
|
15
|
+
DEFAULT_OUT = "tunekeep-out"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _client(args: argparse.Namespace) -> Client:
|
|
19
|
+
return Client.from_env(base_url=args.base_url, azure=args.azure)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _err(msg: str) -> int:
|
|
23
|
+
print(f"tunekeep: {msg}", file=sys.stderr)
|
|
24
|
+
return 2
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def cmd_inventory(args: argparse.Namespace) -> int:
|
|
28
|
+
from .inventory import collect, render
|
|
29
|
+
|
|
30
|
+
out = Path(args.out)
|
|
31
|
+
ensure_private_dir(out)
|
|
32
|
+
inv = collect(_client(args), out, download=not args.no_download)
|
|
33
|
+
print(render(inv))
|
|
34
|
+
print(f"\nFull inventory: {out / 'inventory.json'}")
|
|
35
|
+
return 0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def cmd_record(args: argparse.Namespace) -> int:
|
|
39
|
+
from .record import TERMS_NOTE, PromptError, estimate_tokens, load_prompts, run
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
prompts = load_prompts(Path(args.prompts))
|
|
43
|
+
except (OSError, PromptError) as e:
|
|
44
|
+
return _err(str(e))
|
|
45
|
+
if args.limit:
|
|
46
|
+
prompts = prompts[: args.limit]
|
|
47
|
+
out = Path(args.out) if args.out else Path(DEFAULT_OUT) / f"golden-{_slug(args.model)}.jsonl"
|
|
48
|
+
print(f"{len(prompts)} unique prompts, about {estimate_tokens(prompts):,} input tokens.")
|
|
49
|
+
if not args.model.startswith("ft:"):
|
|
50
|
+
print("Note: this is not a fine-tuned model id (ft:...). Recording anyway.")
|
|
51
|
+
if args.dry_run:
|
|
52
|
+
print(f"Dry run: nothing sent. Output would go to {out}")
|
|
53
|
+
return 0
|
|
54
|
+
print(TERMS_NOTE)
|
|
55
|
+
res = run(
|
|
56
|
+
_client(args),
|
|
57
|
+
args.model,
|
|
58
|
+
prompts,
|
|
59
|
+
out,
|
|
60
|
+
workers=args.workers,
|
|
61
|
+
temperature=None if args.temperature < 0 else args.temperature,
|
|
62
|
+
max_tokens=args.max_tokens,
|
|
63
|
+
seed=args.seed,
|
|
64
|
+
progress=sys.stderr,
|
|
65
|
+
)
|
|
66
|
+
print(f"Recorded {res.recorded}, already had {res.skipped}, failed {res.failed}. Golden set: {out}")
|
|
67
|
+
return _finish(res)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _finish(res) -> int:
|
|
71
|
+
if res.first_error:
|
|
72
|
+
print(f"First error: {res.first_error}", file=sys.stderr)
|
|
73
|
+
print("Run the same command again to retry only the missing prompts.", file=sys.stderr)
|
|
74
|
+
return 1 if res.failed else 0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def cmd_compare(args: argparse.Namespace) -> int:
|
|
78
|
+
from .compare import CompareError, load_golden, load_rows, load_shots, plan_params, render_summary, run, summarize
|
|
79
|
+
from .record import PromptError
|
|
80
|
+
|
|
81
|
+
golden_path = Path(args.golden)
|
|
82
|
+
try:
|
|
83
|
+
golden = load_golden(golden_path)
|
|
84
|
+
shots = load_shots(Path(args.examples)) if args.examples else []
|
|
85
|
+
except (OSError, CompareError, PromptError) as e:
|
|
86
|
+
return _err(str(e))
|
|
87
|
+
if args.limit:
|
|
88
|
+
golden = golden[: args.limit]
|
|
89
|
+
n_shots = args.shots if args.shots is not None else (5 if args.examples else 0)
|
|
90
|
+
if n_shots and not shots:
|
|
91
|
+
return _err("--shots needs --examples with single-turn, plain-text training lines")
|
|
92
|
+
extra = None
|
|
93
|
+
if args.extra_body:
|
|
94
|
+
try:
|
|
95
|
+
extra = json.loads(args.extra_body)
|
|
96
|
+
except ValueError:
|
|
97
|
+
extra = None
|
|
98
|
+
if not isinstance(extra, dict):
|
|
99
|
+
return _err("--extra-body must be a JSON object")
|
|
100
|
+
effort = args.reasoning_effort
|
|
101
|
+
label = args.label or args.candidate + (f" +{n_shots}-shot" if n_shots else "") + (
|
|
102
|
+
f" effort={effort}" if effort != "auto" else "")
|
|
103
|
+
out = Path(args.out) if args.out else golden_path.parent / f"compare-{_slug(label)}.jsonl"
|
|
104
|
+
temperature = None if args.temperature < 0 else args.temperature
|
|
105
|
+
has_tools = any(r["request"].get("tools") for r in golden)
|
|
106
|
+
try:
|
|
107
|
+
params, notes = plan_params(args.candidate, effort, temperature, has_tools)
|
|
108
|
+
except CompareError as e:
|
|
109
|
+
return _err(str(e))
|
|
110
|
+
print(f"{len(golden)} recorded prompts -> {label}" + (f", {n_shots} examples each" if n_shots else ""))
|
|
111
|
+
print(f"Parameters: {json.dumps(params) if params else 'model defaults'}")
|
|
112
|
+
for n in notes:
|
|
113
|
+
print(f"Note: {n}")
|
|
114
|
+
if args.dry_run:
|
|
115
|
+
print(f"Dry run: nothing sent. Output would go to {out}")
|
|
116
|
+
return 0
|
|
117
|
+
if args.candidate_base_url:
|
|
118
|
+
client = Client.for_endpoint(args.candidate_base_url, args.candidate_key_env)
|
|
119
|
+
else:
|
|
120
|
+
client = _client(args)
|
|
121
|
+
try:
|
|
122
|
+
res, _ = run(client, golden, out, candidate=args.candidate, label=label, effort=effort,
|
|
123
|
+
temperature=temperature, max_tokens=args.max_tokens, shots=shots, n_shots=n_shots,
|
|
124
|
+
extra_body=extra, workers=args.workers, progress=sys.stderr)
|
|
125
|
+
except CompareError as e:
|
|
126
|
+
return _err(str(e))
|
|
127
|
+
rows = load_rows(out).get(label) or []
|
|
128
|
+
if rows:
|
|
129
|
+
print(render_summary(summarize(rows)))
|
|
130
|
+
print(f"Compared {res.recorded}, already had {res.skipped}, failed {res.failed}. Results: {out}")
|
|
131
|
+
return _finish(res)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def cmd_retrain(args: argparse.Namespace) -> int:
|
|
135
|
+
from . import retrain as rt
|
|
136
|
+
|
|
137
|
+
data = Path(args.data)
|
|
138
|
+
report = rt.Report()
|
|
139
|
+
try:
|
|
140
|
+
rows = rt.convert_file(data, report)
|
|
141
|
+
val_rows = rt.convert_file(Path(args.validation), rt.Report()) if args.validation else None
|
|
142
|
+
except OSError as e:
|
|
143
|
+
return _err(str(e))
|
|
144
|
+
if val_rows is not None:
|
|
145
|
+
train_rows = rows
|
|
146
|
+
else:
|
|
147
|
+
train_rows, val_rows = rt.split_holdout(rows, args.holdout)
|
|
148
|
+
print(rt.render_report(report, len(train_rows), len(val_rows)))
|
|
149
|
+
if not train_rows:
|
|
150
|
+
return _err("nothing to train on")
|
|
151
|
+
|
|
152
|
+
epochs, job_base = (None, None)
|
|
153
|
+
inv_path = Path(args.inventory) if args.inventory else Path(DEFAULT_OUT) / "inventory.json"
|
|
154
|
+
if inv_path.exists():
|
|
155
|
+
epochs, job_base = rt.epochs_from_inventory(inv_path, args.job)
|
|
156
|
+
if args.epochs:
|
|
157
|
+
epochs = args.epochs
|
|
158
|
+
if epochs:
|
|
159
|
+
print(f"Epochs: {epochs}" + (" (from the original OpenAI job)" if not args.epochs else ""))
|
|
160
|
+
else:
|
|
161
|
+
epochs = 3
|
|
162
|
+
print("Epochs: 3 (default; pass --epochs, or --job with an inventory to reuse the original job's)")
|
|
163
|
+
if job_base:
|
|
164
|
+
print(f"Original job base: {job_base}")
|
|
165
|
+
if args.check:
|
|
166
|
+
print("Check only: nothing written.")
|
|
167
|
+
return 0
|
|
168
|
+
|
|
169
|
+
revision = args.revision
|
|
170
|
+
if revision is None and args.base not in rt.KNOWN_REVISIONS and not Path(args.base).is_dir():
|
|
171
|
+
print(f"Note: {args.base} has no pinned revision; the run records the one it used. Pin with --revision.")
|
|
172
|
+
cfg = rt.build_config(args.base, revision, epochs, args.gpu, rt.sha256_file(data),
|
|
173
|
+
lr=args.lr, lora_r=args.lora_r, max_len=args.max_len, timeout_hours=args.timeout_hours)
|
|
174
|
+
cfg["gguf"] = None if args.gguf == "none" else args.gguf
|
|
175
|
+
out = Path(args.out)
|
|
176
|
+
rt.write_kit(out, train_rows, val_rows, cfg)
|
|
177
|
+
print(f"\nKit written to {out}/ (data files are private: 0600).")
|
|
178
|
+
print("Run it on Modal (needs `modal setup` once; uploads data/ to your Modal account):")
|
|
179
|
+
print(f" cd {out} && modal run modal_app.py --max-steps 5 # smoke test first")
|
|
180
|
+
print(f" cd {out} && modal run modal_app.py")
|
|
181
|
+
print(f"The job stops after {cfg['timeout_hours']} h, which caps its cost. Or run train_lora.py on any CUDA machine "
|
|
182
|
+
"with requirements-train.txt installed.")
|
|
183
|
+
return 0
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def cmd_report(args: argparse.Namespace) -> int:
|
|
187
|
+
from .compare import CompareError
|
|
188
|
+
from .report import build
|
|
189
|
+
|
|
190
|
+
d = Path(args.dir)
|
|
191
|
+
golden = [Path(p) for p in args.golden] if args.golden else sorted(d.glob("golden-*.jsonl"))
|
|
192
|
+
compare = [Path(p) for p in args.compare] if args.compare else sorted(d.glob("compare-*.jsonl"))
|
|
193
|
+
inventory = Path(args.inventory) if args.inventory else d / "inventory.json"
|
|
194
|
+
trained = [Path(p) for p in args.retrain_summary or []]
|
|
195
|
+
if not golden and not compare and not inventory.exists():
|
|
196
|
+
return _err(f"nothing to report in {d}/: run inventory, record or compare first")
|
|
197
|
+
try:
|
|
198
|
+
page = build(golden, compare, inventory, trained)
|
|
199
|
+
except (OSError, CompareError) as e:
|
|
200
|
+
return _err(str(e))
|
|
201
|
+
out = Path(args.out) if args.out else d / "report.html"
|
|
202
|
+
write_private(out, page.encode("utf-8"))
|
|
203
|
+
print(f"Report: {out}")
|
|
204
|
+
return 0
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _slug(model: str) -> str:
|
|
208
|
+
return re.sub(r"[^A-Za-z0-9.-]+", "_", model).strip("_")[:100]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
212
|
+
from .compare import EFFORTS
|
|
213
|
+
from .retrain import DEFAULT_BASE, GPUS
|
|
214
|
+
|
|
215
|
+
p = argparse.ArgumentParser(
|
|
216
|
+
prog="tunekeep",
|
|
217
|
+
description="Keep a fine-tuned model working after its provider retires the base model.",
|
|
218
|
+
)
|
|
219
|
+
p.add_argument("--version", action="version", version=f"tunekeep {__version__}")
|
|
220
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
221
|
+
common.add_argument("--base-url", help="API base URL (default: OPENAI_BASE_URL or https://api.openai.com/v1)")
|
|
222
|
+
common.add_argument("--azure", action="store_true", help="Azure OpenAI v1 API (uses AZURE_OPENAI_API_KEY); experimental")
|
|
223
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
224
|
+
|
|
225
|
+
inv = sub.add_parser("inventory", parents=[common], help="List fine-tuned models, jobs, files and shutdown dates")
|
|
226
|
+
inv.add_argument("--out", default=DEFAULT_OUT, help=f"Output directory (default: {DEFAULT_OUT})")
|
|
227
|
+
inv.add_argument("--no-download", action="store_true", help="Do not try to download job files")
|
|
228
|
+
inv.set_defaults(func=cmd_inventory)
|
|
229
|
+
|
|
230
|
+
rec = sub.add_parser("record", parents=[common], help="Record a fine-tuned model's answers as a golden eval set")
|
|
231
|
+
rec.add_argument("--model", required=True, help="Fine-tuned model id, e.g. ft:gpt-4o-mini-2024-07-18:org::abc123")
|
|
232
|
+
rec.add_argument("--prompts", required=True, help="JSONL of prompts (OpenAI training format works as-is)")
|
|
233
|
+
rec.add_argument("--out", help=f"Output JSONL (default: {DEFAULT_OUT}/golden-<model>.jsonl)")
|
|
234
|
+
rec.add_argument("--workers", type=int, default=4, help="Parallel requests (default: 4)")
|
|
235
|
+
rec.add_argument("--temperature", type=float, default=0.0, help="Sampling temperature (default 0; -1 to omit)")
|
|
236
|
+
rec.add_argument("--max-tokens", type=int, help="max_completion_tokens per answer")
|
|
237
|
+
rec.add_argument("--seed", type=int, help="Seed for more repeatable answers")
|
|
238
|
+
rec.add_argument("--limit", type=int, help="Only the first N prompts")
|
|
239
|
+
rec.add_argument("--dry-run", action="store_true", help="Count prompts and tokens; send nothing")
|
|
240
|
+
rec.set_defaults(func=cmd_record)
|
|
241
|
+
|
|
242
|
+
cmp = sub.add_parser("compare", parents=[common], help="Score another model against a recorded golden set")
|
|
243
|
+
cmp.add_argument("--golden", required=True, help="Golden set from `tunekeep record`")
|
|
244
|
+
cmp.add_argument("--candidate", required=True, help="Model to test, e.g. gpt-6-luna, or your served retrained model")
|
|
245
|
+
cmp.add_argument("--candidate-base-url", help="OpenAI-compatible endpoint for the candidate, e.g. http://127.0.0.1:8080/v1")
|
|
246
|
+
cmp.add_argument("--candidate-key-env", help="Env var holding the candidate endpoint's key (none needed for localhost)")
|
|
247
|
+
cmp.add_argument("--examples", help="Your training JSONL, for few-shot examples (your own data only)")
|
|
248
|
+
cmp.add_argument("--shots", type=int, help="Examples per prompt (default 5 with --examples)")
|
|
249
|
+
cmp.add_argument("--reasoning-effort", choices=EFFORTS, default="auto",
|
|
250
|
+
help="auto: 'none' for GPT-6 Luna/Sol and GPT-5.x so tools and temperature work; "
|
|
251
|
+
"'default' sends nothing")
|
|
252
|
+
cmp.add_argument("--temperature", type=float, default=0.0, help="Sampling temperature (default 0; -1 to omit)")
|
|
253
|
+
cmp.add_argument("--max-tokens", type=int, help="max_completion_tokens per answer")
|
|
254
|
+
cmp.add_argument("--extra-body", help='JSON merged into each request, e.g. \'{"chat_template_kwargs":{"enable_thinking":false}}\'')
|
|
255
|
+
cmp.add_argument("--label", help="Name for this run in results and the report")
|
|
256
|
+
cmp.add_argument("--workers", type=int, default=4, help="Parallel requests (default: 4)")
|
|
257
|
+
cmp.add_argument("--limit", type=int, help="Only the first N prompts")
|
|
258
|
+
cmp.add_argument("--out", help="Output JSONL (default: next to the golden set)")
|
|
259
|
+
cmp.add_argument("--dry-run", action="store_true", help="Show what would be sent; send nothing")
|
|
260
|
+
cmp.set_defaults(func=cmd_compare)
|
|
261
|
+
|
|
262
|
+
rt = sub.add_parser("retrain", help="Write a LoRA retraining kit for an open model from your training data")
|
|
263
|
+
rt.add_argument("--data", required=True, help="Your original OpenAI training JSONL")
|
|
264
|
+
rt.add_argument("--validation", help="Your validation JSONL (default: hold out a slice of --data)")
|
|
265
|
+
rt.add_argument("--holdout", type=float, default=0.05, help="Validation share when no --validation (default 0.05)")
|
|
266
|
+
rt.add_argument("--base", default=DEFAULT_BASE, help=f"Hugging Face model id or local path (default: {DEFAULT_BASE})")
|
|
267
|
+
rt.add_argument("--revision", help="Hugging Face revision (commit) to pin")
|
|
268
|
+
rt.add_argument("--epochs", type=int, help="Epochs (default: the original job's, else 3)")
|
|
269
|
+
rt.add_argument("--inventory", help=f"inventory.json, to reuse the original job's epochs (default: {DEFAULT_OUT}/inventory.json)")
|
|
270
|
+
rt.add_argument("--job", help="Fine-tuning job id in the inventory to copy settings from")
|
|
271
|
+
rt.add_argument("--lr", type=float, help="Learning rate (default 2e-4)")
|
|
272
|
+
rt.add_argument("--lora-r", type=int, help="LoRA rank (default 16)")
|
|
273
|
+
rt.add_argument("--max-len", type=int, help="Max tokens per sample; longer ones are skipped (default 4096)")
|
|
274
|
+
rt.add_argument("--gpu", choices=GPUS, default="L40S", help="Modal GPU (default L40S)")
|
|
275
|
+
rt.add_argument("--timeout-hours", type=float, help="Hard stop for the Modal job (default 2)")
|
|
276
|
+
rt.add_argument("--gguf", default="q8_0", choices=["q8_0", "bf16", "f16", "none"], help="GGUF export (default q8_0)")
|
|
277
|
+
rt.add_argument("--out", default=f"{DEFAULT_OUT}/retrain", help=f"Kit directory (default: {DEFAULT_OUT}/retrain)")
|
|
278
|
+
rt.add_argument("--check", action="store_true", help="Only check and summarise the data")
|
|
279
|
+
rt.set_defaults(func=cmd_retrain)
|
|
280
|
+
|
|
281
|
+
rp = sub.add_parser("report", help="Write one self-contained HTML report")
|
|
282
|
+
rp.add_argument("--dir", default=DEFAULT_OUT, help=f"Where to look for results (default: {DEFAULT_OUT})")
|
|
283
|
+
rp.add_argument("--golden", nargs="*", help="Golden sets (default: <dir>/golden-*.jsonl)")
|
|
284
|
+
rp.add_argument("--compare", nargs="*", help="Compare results (default: <dir>/compare-*.jsonl)")
|
|
285
|
+
rp.add_argument("--inventory", help="inventory.json (default: <dir>/inventory.json)")
|
|
286
|
+
rp.add_argument("--retrain-summary", nargs="*", help="summary.json files from retrain runs")
|
|
287
|
+
rp.add_argument("--out", help="Output HTML (default: <dir>/report.html)")
|
|
288
|
+
rp.set_defaults(func=cmd_report)
|
|
289
|
+
return p
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def main(argv: list[str] | None = None) -> int:
|
|
293
|
+
args = build_parser().parse_args(argv)
|
|
294
|
+
try:
|
|
295
|
+
return args.func(args)
|
|
296
|
+
except APIError as e:
|
|
297
|
+
print(f"tunekeep: {e}", file=sys.stderr)
|
|
298
|
+
return 1
|
|
299
|
+
except KeyboardInterrupt:
|
|
300
|
+
print("\ntunekeep: interrupted. Re-run to continue where you left off.", file=sys.stderr)
|
|
301
|
+
return 130
|
tunekeep/client.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""A small OpenAI API client built on the standard library.
|
|
2
|
+
|
|
3
|
+
Only the endpoints tunekeep needs. No third-party packages, so there is no
|
|
4
|
+
dependency tree to audit. The API key is sent in a header and never logged.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import random
|
|
12
|
+
import time
|
|
13
|
+
import urllib.error
|
|
14
|
+
import urllib.parse
|
|
15
|
+
import urllib.request
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Any, Iterator
|
|
18
|
+
|
|
19
|
+
from . import __version__
|
|
20
|
+
|
|
21
|
+
DEFAULT_BASE_URL = "https://api.openai.com/v1"
|
|
22
|
+
RETRY_STATUSES = {408, 409, 429, 500, 502, 503, 504}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class APIError(Exception):
|
|
26
|
+
"""An error response from the API, with the status and OpenAI's message."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, status: int, message: str, code: str | None = None):
|
|
29
|
+
super().__init__(f"HTTP {status}: {message}")
|
|
30
|
+
self.status = status
|
|
31
|
+
self.message = message
|
|
32
|
+
self.code = code
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class Client:
|
|
37
|
+
api_key: str
|
|
38
|
+
base_url: str = DEFAULT_BASE_URL
|
|
39
|
+
organization: str | None = None
|
|
40
|
+
project: str | None = None
|
|
41
|
+
azure: bool = False
|
|
42
|
+
timeout: float = 120.0
|
|
43
|
+
max_retries: int = 5
|
|
44
|
+
_sleep: Any = field(default=time.sleep, repr=False)
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def from_env(cls, base_url: str | None = None, azure: bool = False) -> "Client":
|
|
48
|
+
if azure:
|
|
49
|
+
key = os.environ.get("AZURE_OPENAI_API_KEY", "").strip()
|
|
50
|
+
name = "AZURE_OPENAI_API_KEY"
|
|
51
|
+
else:
|
|
52
|
+
key = os.environ.get("OPENAI_API_KEY", "").strip()
|
|
53
|
+
name = "OPENAI_API_KEY"
|
|
54
|
+
if not key:
|
|
55
|
+
raise SystemExit(f"tunekeep: set {name} in your environment first.")
|
|
56
|
+
url = base_url or os.environ.get("OPENAI_BASE_URL", "").strip() or DEFAULT_BASE_URL
|
|
57
|
+
if azure and url == DEFAULT_BASE_URL:
|
|
58
|
+
raise SystemExit(
|
|
59
|
+
"tunekeep: with --azure, pass --base-url https://<resource>.openai.azure.com/openai/v1"
|
|
60
|
+
)
|
|
61
|
+
return cls(
|
|
62
|
+
api_key=key,
|
|
63
|
+
base_url=url.rstrip("/"),
|
|
64
|
+
organization=os.environ.get("OPENAI_ORG_ID") or None,
|
|
65
|
+
project=os.environ.get("OPENAI_PROJECT_ID") or None,
|
|
66
|
+
azure=azure,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def for_endpoint(cls, base_url: str, key_env: str | None) -> "Client":
|
|
71
|
+
"""A client for a candidate endpoint. A missing key is fine for localhost."""
|
|
72
|
+
key = os.environ.get(key_env, "").strip() if key_env else ""
|
|
73
|
+
host = urllib.parse.urlparse(base_url).hostname or ""
|
|
74
|
+
if not key and host not in ("localhost", "127.0.0.1", "::1"):
|
|
75
|
+
raise SystemExit(f"tunekeep: set {key_env or 'a key variable'} for {base_url}, or use --candidate-key-env.")
|
|
76
|
+
return cls(api_key=key, base_url=base_url.rstrip("/"))
|
|
77
|
+
|
|
78
|
+
# -- low level -----------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
def _headers(self, accept_json: bool = True) -> dict[str, str]:
|
|
81
|
+
h = {"User-Agent": f"tunekeep/{__version__}"}
|
|
82
|
+
if not self.api_key:
|
|
83
|
+
pass # local OpenAI-compatible servers (llama-server, vLLM, Ollama) often need no key
|
|
84
|
+
elif self.azure:
|
|
85
|
+
h["api-key"] = self.api_key
|
|
86
|
+
else:
|
|
87
|
+
h["Authorization"] = f"Bearer {self.api_key}"
|
|
88
|
+
if self.organization:
|
|
89
|
+
h["OpenAI-Organization"] = self.organization
|
|
90
|
+
if self.project:
|
|
91
|
+
h["OpenAI-Project"] = self.project
|
|
92
|
+
if accept_json:
|
|
93
|
+
h["Accept"] = "application/json"
|
|
94
|
+
return h
|
|
95
|
+
|
|
96
|
+
def _url(self, path: str, params: dict[str, Any] | None = None) -> str:
|
|
97
|
+
url = f"{self.base_url}/{path.lstrip('/')}"
|
|
98
|
+
if params:
|
|
99
|
+
clean = {k: v for k, v in params.items() if v is not None}
|
|
100
|
+
if clean:
|
|
101
|
+
url += "?" + urllib.parse.urlencode(clean)
|
|
102
|
+
return url
|
|
103
|
+
|
|
104
|
+
def _request(self, method: str, path: str, *, params=None, body=None, raw=False):
|
|
105
|
+
data = None
|
|
106
|
+
headers = self._headers(accept_json=not raw)
|
|
107
|
+
if body is not None:
|
|
108
|
+
data = json.dumps(body).encode("utf-8")
|
|
109
|
+
headers["Content-Type"] = "application/json"
|
|
110
|
+
url = self._url(path, params)
|
|
111
|
+
attempt = 0
|
|
112
|
+
while True:
|
|
113
|
+
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
|
114
|
+
try:
|
|
115
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
116
|
+
payload = resp.read()
|
|
117
|
+
return payload if raw else json.loads(payload or b"null")
|
|
118
|
+
except urllib.error.HTTPError as e:
|
|
119
|
+
status = e.code
|
|
120
|
+
text = e.read().decode("utf-8", "replace")
|
|
121
|
+
if status in RETRY_STATUSES and attempt < self.max_retries:
|
|
122
|
+
self._sleep(self._backoff(attempt, e.headers.get("Retry-After")))
|
|
123
|
+
attempt += 1
|
|
124
|
+
continue
|
|
125
|
+
raise _api_error(status, text) from None
|
|
126
|
+
except (urllib.error.URLError, TimeoutError, ConnectionError) as e:
|
|
127
|
+
if attempt < self.max_retries:
|
|
128
|
+
self._sleep(self._backoff(attempt, None))
|
|
129
|
+
attempt += 1
|
|
130
|
+
continue
|
|
131
|
+
reason = getattr(e, "reason", e)
|
|
132
|
+
raise APIError(0, f"could not reach {self.base_url}: {reason}") from None
|
|
133
|
+
|
|
134
|
+
@staticmethod
|
|
135
|
+
def _backoff(attempt: int, retry_after: str | None) -> float:
|
|
136
|
+
if retry_after:
|
|
137
|
+
try:
|
|
138
|
+
return min(60.0, max(0.0, float(retry_after)))
|
|
139
|
+
except ValueError:
|
|
140
|
+
pass
|
|
141
|
+
return min(30.0, 0.5 * (2**attempt)) + random.uniform(0, 0.25)
|
|
142
|
+
|
|
143
|
+
def get(self, path: str, **params) -> Any:
|
|
144
|
+
return self._request("GET", path, params=params)
|
|
145
|
+
|
|
146
|
+
def post(self, path: str, body: dict[str, Any]) -> Any:
|
|
147
|
+
return self._request("POST", path, body=body)
|
|
148
|
+
|
|
149
|
+
def paginate(self, path: str, limit: int = 100, **params) -> Iterator[dict[str, Any]]:
|
|
150
|
+
"""Yield every item of a cursor-paginated list (`after` = last id)."""
|
|
151
|
+
after = None
|
|
152
|
+
while True:
|
|
153
|
+
page = self.get(path, limit=limit, after=after, **params)
|
|
154
|
+
items = page.get("data") or []
|
|
155
|
+
yield from items
|
|
156
|
+
if not page.get("has_more") or not items:
|
|
157
|
+
return
|
|
158
|
+
after = page.get("last_id") or items[-1].get("id")
|
|
159
|
+
|
|
160
|
+
# -- endpoints -----------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
def list_models(self) -> list[dict[str, Any]]:
|
|
163
|
+
return self.get("models").get("data") or []
|
|
164
|
+
|
|
165
|
+
def list_jobs(self) -> list[dict[str, Any]]:
|
|
166
|
+
return list(self.paginate("fine_tuning/jobs"))
|
|
167
|
+
|
|
168
|
+
def list_checkpoints(self, job_id: str) -> list[dict[str, Any]]:
|
|
169
|
+
return list(self.paginate(f"fine_tuning/jobs/{urllib.parse.quote(job_id)}/checkpoints"))
|
|
170
|
+
|
|
171
|
+
def get_file(self, file_id: str) -> dict[str, Any]:
|
|
172
|
+
return self.get(f"files/{urllib.parse.quote(file_id)}")
|
|
173
|
+
|
|
174
|
+
def file_content(self, file_id: str) -> bytes:
|
|
175
|
+
return self._request("GET", f"files/{urllib.parse.quote(file_id)}/content", raw=True)
|
|
176
|
+
|
|
177
|
+
def chat(self, body: dict[str, Any]) -> dict[str, Any]:
|
|
178
|
+
return self.post("chat/completions", body)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _api_error(status: int, text: str) -> APIError:
|
|
182
|
+
message, code = text.strip()[:500] or "no response body", None
|
|
183
|
+
try:
|
|
184
|
+
err = json.loads(text).get("error") or {}
|
|
185
|
+
message = err.get("message") or message
|
|
186
|
+
code = err.get("code") or err.get("type")
|
|
187
|
+
except (ValueError, AttributeError):
|
|
188
|
+
pass
|
|
189
|
+
return APIError(status, message, code)
|