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 ADDED
@@ -0,0 +1,3 @@
1
+ """tunekeep: keep a fine-tuned model working after its provider retires the base model."""
2
+
3
+ __version__ = "0.2.1"
tunekeep/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
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)