runapprentice 0.4.1__tar.gz → 0.4.3__tar.gz
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.
- {runapprentice-0.4.1 → runapprentice-0.4.3}/.gitignore +1 -0
- {runapprentice-0.4.1 → runapprentice-0.4.3}/PKG-INFO +5 -5
- {runapprentice-0.4.1 → runapprentice-0.4.3}/README.md +4 -4
- {runapprentice-0.4.1 → runapprentice-0.4.3}/pyproject.toml +1 -1
- {runapprentice-0.4.1 → runapprentice-0.4.3}/src/runapprentice/cli.py +101 -10
- {runapprentice-0.4.1 → runapprentice-0.4.3}/src/runapprentice/client.py +99 -9
- {runapprentice-0.4.1 → runapprentice-0.4.3}/src/runapprentice/local.py +104 -31
- {runapprentice-0.4.1 → runapprentice-0.4.3}/src/runapprentice/models.py +16 -0
- {runapprentice-0.4.1 → runapprentice-0.4.3}/tests/test_cli.py +306 -3
- {runapprentice-0.4.1 → runapprentice-0.4.3}/tests/test_client.py +294 -1
- {runapprentice-0.4.1 → runapprentice-0.4.3}/LICENSE +0 -0
- {runapprentice-0.4.1 → runapprentice-0.4.3}/src/runapprentice/__init__.py +0 -0
- {runapprentice-0.4.1 → runapprentice-0.4.3}/src/runapprentice/_capture.py +0 -0
- {runapprentice-0.4.1 → runapprentice-0.4.3}/src/runapprentice/langchain.py +0 -0
- {runapprentice-0.4.1 → runapprentice-0.4.3}/src/runapprentice/openai.py +0 -0
- {runapprentice-0.4.1 → runapprentice-0.4.3}/src/runapprentice/py.typed +0 -0
- {runapprentice-0.4.1 → runapprentice-0.4.3}/tests/test_capture.py +0 -0
- {runapprentice-0.4.1 → runapprentice-0.4.3}/tests/test_dispatcher.py +0 -0
- {runapprentice-0.4.1 → runapprentice-0.4.3}/tests/test_failopen.py +0 -0
- {runapprentice-0.4.1 → runapprentice-0.4.3}/tests/test_langchain_prompts.py +0 -0
- {runapprentice-0.4.1 → runapprentice-0.4.3}/tests/test_local.py +0 -0
- {runapprentice-0.4.1 → runapprentice-0.4.3}/tests/test_openai.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: runapprentice
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.3
|
|
4
4
|
Summary: Cut LLM cost without losing quality: capture traffic, build golden datasets, and optimize prompts.
|
|
5
5
|
Project-URL: Homepage, https://runapprentice.com
|
|
6
6
|
Project-URL: Documentation, https://docs.runapprentice.com
|
|
@@ -50,10 +50,10 @@ apprentice optimize <task> --local --data golden.csv # free, your O
|
|
|
50
50
|
apprentice train <task> --local --data golden.csv --effort high # free, fine-tunes on your Mac
|
|
51
51
|
```
|
|
52
52
|
|
|
53
|
-
Both commands work
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
53
|
+
Both `--local --data` commands work end to end from a CSV. Hosted setup requires the console
|
|
54
|
+
or Python SDK to create a task and upload its dataset; the CLI has no create or upload command.
|
|
55
|
+
`apprentice --help` (or any subcommand `--help`) documents every flag; `--json` gives clean
|
|
56
|
+
machine-readable output.
|
|
57
57
|
|
|
58
58
|
## Install
|
|
59
59
|
|
|
@@ -21,10 +21,10 @@ apprentice optimize <task> --local --data golden.csv # free, your O
|
|
|
21
21
|
apprentice train <task> --local --data golden.csv --effort high # free, fine-tunes on your Mac
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
Both commands work
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
Both `--local --data` commands work end to end from a CSV. Hosted setup requires the console
|
|
25
|
+
or Python SDK to create a task and upload its dataset; the CLI has no create or upload command.
|
|
26
|
+
`apprentice --help` (or any subcommand `--help`) documents every flag; `--json` gives clean
|
|
27
|
+
machine-readable output.
|
|
28
28
|
|
|
29
29
|
## Install
|
|
30
30
|
|
|
@@ -8,9 +8,12 @@ import json
|
|
|
8
8
|
import os
|
|
9
9
|
import stat
|
|
10
10
|
import sys
|
|
11
|
+
import tempfile
|
|
11
12
|
from pathlib import Path
|
|
12
13
|
from typing import Any
|
|
13
14
|
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
14
17
|
from runapprentice.client import Apprentice, ApprenticeError
|
|
15
18
|
from runapprentice import local as _local
|
|
16
19
|
from runapprentice.models import (
|
|
@@ -51,7 +54,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
51
54
|
args = parser.parse_args(argv)
|
|
52
55
|
if args.command == "login":
|
|
53
56
|
return _login(args)
|
|
54
|
-
|
|
57
|
+
try:
|
|
58
|
+
api_key = _api_key()
|
|
59
|
+
except ApprenticeError as exc:
|
|
60
|
+
return _fail(str(exc), bool(args.json))
|
|
55
61
|
client = Apprentice(api_key=api_key)
|
|
56
62
|
setattr(args, "_api_key_present", api_key is not None)
|
|
57
63
|
keyless_local_optimize = args.command == "optimize" and args.local and api_key is None
|
|
@@ -106,7 +112,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
106
112
|
optimize.add_argument(
|
|
107
113
|
"--local",
|
|
108
114
|
action="store_true",
|
|
109
|
-
help=
|
|
115
|
+
help=(
|
|
116
|
+
"run GEPA on this machine with your own OPENAI_API_KEY, no Apprentice account "
|
|
117
|
+
"needed; scores JSON extraction only, so every output must be a JSON object or array"
|
|
118
|
+
),
|
|
110
119
|
)
|
|
111
120
|
optimize.add_argument(
|
|
112
121
|
"--data",
|
|
@@ -168,7 +177,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
168
177
|
)
|
|
169
178
|
train.set_defaults(func=_train)
|
|
170
179
|
|
|
171
|
-
jobs = sub.add_parser("jobs", help="list hosted
|
|
180
|
+
jobs = sub.add_parser("jobs", help="list hosted jobs for a task, newest first")
|
|
172
181
|
jobs.add_argument("task", help="task name")
|
|
173
182
|
jobs.set_defaults(func=_jobs)
|
|
174
183
|
|
|
@@ -183,13 +192,70 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
183
192
|
|
|
184
193
|
|
|
185
194
|
def _login(args: argparse.Namespace) -> int:
|
|
186
|
-
key =
|
|
195
|
+
key = (
|
|
196
|
+
getpass.getpass("Apprentice API key: ") if sys.stdin.isatty() else sys.stdin.read()
|
|
197
|
+
).strip()
|
|
187
198
|
if not key:
|
|
188
199
|
return _fail("API key required", bool(args.json))
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
200
|
+
# The client rejects a key that cannot become an HTTP header (whitespace from a
|
|
201
|
+
# two-line paste, a smart quote). Letting it raise keeps one definition of what
|
|
202
|
+
# a usable key is, shared with the constructor and APPRENTICE_API_KEY.
|
|
203
|
+
# No captures happen during login, so skip the background dispatcher thread.
|
|
204
|
+
try:
|
|
205
|
+
client = Apprentice(api_key=key, capture_async=False)
|
|
206
|
+
except ApprenticeError as exc:
|
|
207
|
+
return _fail(f"{exc}, so it was not saved.", bool(args.json))
|
|
208
|
+
try:
|
|
209
|
+
response = client._http.get("/v1/tasks")
|
|
210
|
+
except httpx.HTTPError:
|
|
211
|
+
return _fail(
|
|
212
|
+
"Could not verify API key because Apprentice API is unavailable. "
|
|
213
|
+
"API key was not saved.",
|
|
214
|
+
bool(args.json),
|
|
215
|
+
)
|
|
216
|
+
if response.status_code in {401, 403}:
|
|
217
|
+
return _fail("API key was rejected and was not saved.", bool(args.json))
|
|
218
|
+
if not response.is_success:
|
|
219
|
+
return _fail(
|
|
220
|
+
"Could not verify API key because Apprentice API is unavailable. "
|
|
221
|
+
"API key was not saved.",
|
|
222
|
+
bool(args.json),
|
|
223
|
+
)
|
|
224
|
+
# The key is valid past this point, so a filesystem failure is the only thing
|
|
225
|
+
# left that can go wrong. Report it the same way as every other failure rather
|
|
226
|
+
# than as a traceback that emits no JSON in --json mode.
|
|
227
|
+
try:
|
|
228
|
+
return _save_api_key(key, bool(args.json))
|
|
229
|
+
except OSError as exc:
|
|
230
|
+
return _fail(f"Could not save the API key to {CONFIG_PATH}: {exc}", bool(args.json))
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _save_api_key(key: str, as_json: bool) -> int:
|
|
234
|
+
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
235
|
+
# mkdir's mode is masked by the process umask, so set it explicitly. A
|
|
236
|
+
# world-writable directory lets another local account replace the key file
|
|
237
|
+
# whatever the file's own mode is, because directory write governs entries.
|
|
238
|
+
CONFIG_PATH.parent.chmod(stat.S_IRWXU)
|
|
239
|
+
# Write private-then-rename rather than write-then-chmod. Writing first would
|
|
240
|
+
# leave the key world-readable (0644 under a normal umask, 0666 under a
|
|
241
|
+
# permissive one) until the chmod lands, and a process killed in that window
|
|
242
|
+
# leaves it that way for good. mkstemp creates at 0600, and os.replace is
|
|
243
|
+
# atomic, so a concurrent reader sees either the old file or the new one and
|
|
244
|
+
# never a half-written one. The rename also lands on the path itself, so a
|
|
245
|
+
# config.json symlink is replaced instead of its target being overwritten.
|
|
246
|
+
handle_fd, temporary_path = tempfile.mkstemp(dir=CONFIG_PATH.parent, prefix=".config-")
|
|
247
|
+
try:
|
|
248
|
+
with os.fdopen(handle_fd, "w", encoding="utf-8") as handle:
|
|
249
|
+
handle.write(json.dumps({"api_key": key}) + "\n")
|
|
250
|
+
os.replace(temporary_path, CONFIG_PATH)
|
|
251
|
+
except BaseException:
|
|
252
|
+
os.unlink(temporary_path)
|
|
253
|
+
raise
|
|
254
|
+
return _emit(
|
|
255
|
+
{"config": str(CONFIG_PATH), "mode": "600", "validated": True},
|
|
256
|
+
f"API key verified and saved to {CONFIG_PATH}.",
|
|
257
|
+
as_json,
|
|
258
|
+
)
|
|
193
259
|
|
|
194
260
|
|
|
195
261
|
def _status(args: argparse.Namespace, client: Apprentice) -> int:
|
|
@@ -474,9 +540,34 @@ def _api_key() -> str | None:
|
|
|
474
540
|
return os.environ["APPRENTICE_API_KEY"]
|
|
475
541
|
if not CONFIG_PATH.exists():
|
|
476
542
|
return None
|
|
477
|
-
|
|
543
|
+
try:
|
|
544
|
+
data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
|
545
|
+
except json.JSONDecodeError as exc:
|
|
546
|
+
raise ApprenticeError(
|
|
547
|
+
f"Could not read {CONFIG_PATH}: invalid JSON. "
|
|
548
|
+
"Run `apprentice login` to replace it."
|
|
549
|
+
) from exc
|
|
550
|
+
except (OSError, UnicodeError) as exc:
|
|
551
|
+
raise ApprenticeError(
|
|
552
|
+
f"Could not read {CONFIG_PATH}: {exc}. "
|
|
553
|
+
"Fix its permissions or set APPRENTICE_API_KEY."
|
|
554
|
+
) from exc
|
|
555
|
+
if not isinstance(data, dict):
|
|
556
|
+
raise ApprenticeError(
|
|
557
|
+
f"Could not read {CONFIG_PATH}: expected a JSON object. "
|
|
558
|
+
"Run `apprentice login` to replace it."
|
|
559
|
+
)
|
|
478
560
|
key = data.get("api_key")
|
|
479
|
-
|
|
561
|
+
if key is None:
|
|
562
|
+
return None
|
|
563
|
+
# Present but not a string: the file is malformed, which is not the same as
|
|
564
|
+
# having no key. Saying so beats silently falling through to an auth error.
|
|
565
|
+
if not isinstance(key, str):
|
|
566
|
+
raise ApprenticeError(
|
|
567
|
+
f"Could not read {CONFIG_PATH}: api_key must be a string. "
|
|
568
|
+
"Run `apprentice login` to replace it."
|
|
569
|
+
)
|
|
570
|
+
return key
|
|
480
571
|
|
|
481
572
|
|
|
482
573
|
def _job_summary(job: JobSummary) -> dict[str, Any]:
|
|
@@ -35,6 +35,7 @@ from runapprentice.models import (
|
|
|
35
35
|
)
|
|
36
36
|
|
|
37
37
|
logger = logging.getLogger("runapprentice")
|
|
38
|
+
logger.addHandler(logging.NullHandler())
|
|
38
39
|
|
|
39
40
|
_DEFAULT_BASE_URL = "https://api.runapprentice.com"
|
|
40
41
|
_DEFAULT_TIMEOUT = 10.0
|
|
@@ -64,6 +65,45 @@ class ApprenticeError(Exception):
|
|
|
64
65
|
"""Raised for control-plane API failures."""
|
|
65
66
|
|
|
66
67
|
|
|
68
|
+
def _reject_unusable_api_key(api_key: str) -> None:
|
|
69
|
+
"""Reject a key that cannot become an HTTP header.
|
|
70
|
+
|
|
71
|
+
httpx encodes header values as ASCII, so a key carrying a smart quote or an
|
|
72
|
+
accented character raises UnicodeEncodeError deep inside the transport on the
|
|
73
|
+
first request. Checked here so every path that supplies a key (constructor,
|
|
74
|
+
APPRENTICE_API_KEY, the CLI config file) fails with the same clear error.
|
|
75
|
+
"""
|
|
76
|
+
if any(character.isspace() for character in api_key):
|
|
77
|
+
raise ApprenticeError("API key contains whitespace")
|
|
78
|
+
if not api_key.isascii() or not api_key.isprintable():
|
|
79
|
+
raise ApprenticeError("API key has characters that are not printable ASCII")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _printable(text: str) -> str:
|
|
83
|
+
"""Drop control characters from server-controlled text.
|
|
84
|
+
|
|
85
|
+
The message goes to a terminal and to logs. Escape sequences in it can clear
|
|
86
|
+
the screen, move the cursor, or repaint earlier lines, so a hostile or
|
|
87
|
+
compromised backend could make its error read as something else entirely.
|
|
88
|
+
"""
|
|
89
|
+
return "".join(character if character.isprintable() else " " for character in text)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _reason(resp: httpx.Response) -> str:
|
|
93
|
+
"""The API's own explanation for a failed response, falling back to its body."""
|
|
94
|
+
try:
|
|
95
|
+
payload = resp.json()
|
|
96
|
+
except ValueError:
|
|
97
|
+
payload = None
|
|
98
|
+
# A body is not always a JSON object: a gateway can return a bare list or
|
|
99
|
+
# string, and FastAPI sends a list of dicts for 422. Only a string `detail`
|
|
100
|
+
# on an object is a written reason; everything else falls back to the body.
|
|
101
|
+
detail = payload.get("detail") if isinstance(payload, dict) else None
|
|
102
|
+
if isinstance(detail, str) and detail.strip():
|
|
103
|
+
return _printable(detail.strip())[:500]
|
|
104
|
+
return _printable(resp.text[:500]) or f"HTTP {resp.status_code}"
|
|
105
|
+
|
|
106
|
+
|
|
67
107
|
class Apprentice:
|
|
68
108
|
def __init__(
|
|
69
109
|
self,
|
|
@@ -78,6 +118,8 @@ class Apprentice:
|
|
|
78
118
|
base_url or os.environ.get("APPRENTICE_BASE_URL") or _DEFAULT_BASE_URL
|
|
79
119
|
).rstrip("/")
|
|
80
120
|
api_key = api_key or os.environ.get("APPRENTICE_API_KEY")
|
|
121
|
+
if api_key is not None:
|
|
122
|
+
_reject_unusable_api_key(api_key)
|
|
81
123
|
self._api_key_present = api_key is not None
|
|
82
124
|
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
|
83
125
|
self._http = httpx.Client(base_url=self.base_url, headers=headers, timeout=timeout)
|
|
@@ -97,24 +139,50 @@ class Apprentice:
|
|
|
97
139
|
|
|
98
140
|
def _request(self, method: str, path: str, json: Any | None = None) -> Any:
|
|
99
141
|
t0 = time.monotonic()
|
|
142
|
+
# The path carries caller-supplied names, so an absurd one would otherwise
|
|
143
|
+
# put its whole length into every message and log line, defeating the cap
|
|
144
|
+
# on the response body. httpx.InvalidURL is not an HTTPError, so an
|
|
145
|
+
# over-long path would escape the handler below.
|
|
146
|
+
shown_path = path if len(path) <= 200 else f"{path[:200]}... ({len(path)} chars)"
|
|
100
147
|
try:
|
|
101
148
|
resp = self._http.request(method, path, json=json)
|
|
149
|
+
except httpx.InvalidURL as exc:
|
|
150
|
+
raise ApprenticeError(f"{method} {shown_path} is not a valid URL: {exc}") from exc
|
|
102
151
|
except httpx.HTTPError as exc:
|
|
103
|
-
logger.error("%s %s failed after %.0fms: %s", method,
|
|
152
|
+
logger.error("%s %s failed after %.0fms: %s", method, shown_path, _ms(t0), exc)
|
|
104
153
|
raise ApprenticeError(
|
|
105
|
-
f"{method} {
|
|
154
|
+
f"{method} {shown_path} failed: {exc}. "
|
|
106
155
|
f"Is the Apprentice backend reachable at {self.base_url}?"
|
|
107
156
|
) from exc
|
|
108
|
-
logger.debug("%s %s -> %s (%.0fms)", method,
|
|
157
|
+
logger.debug("%s %s -> %s (%.0fms)", method, shown_path, resp.status_code, _ms(t0))
|
|
109
158
|
if resp.status_code >= 400:
|
|
110
|
-
logger.error(
|
|
159
|
+
logger.error(
|
|
160
|
+
"%s %s -> %s error=http_response", method, shown_path, resp.status_code
|
|
161
|
+
)
|
|
162
|
+
# The API sends a written reason in `detail`; lead with it so the first
|
|
163
|
+
# thing a reader sees is why the call failed. The route cannot tell us
|
|
164
|
+
# that: a 404 on /v1/prompts/{task} means the task has no optimized
|
|
165
|
+
# prompt yet, not that the task is missing.
|
|
111
166
|
suffix = (
|
|
112
|
-
"
|
|
167
|
+
" Get a free Apprentice API key at https://runapprentice.com"
|
|
113
168
|
if resp.status_code in {401, 403}
|
|
114
169
|
else ""
|
|
115
170
|
)
|
|
116
|
-
raise ApprenticeError(
|
|
117
|
-
|
|
171
|
+
raise ApprenticeError(
|
|
172
|
+
f"{_reason(resp)} ({method} {shown_path} -> {resp.status_code}){suffix}"
|
|
173
|
+
)
|
|
174
|
+
try:
|
|
175
|
+
return resp.json()
|
|
176
|
+
except ValueError as exc:
|
|
177
|
+
# A success with no body (204) or a non-JSON body from a proxy would
|
|
178
|
+
# otherwise raise JSONDecodeError, escaping every caller that catches
|
|
179
|
+
# ApprenticeError.
|
|
180
|
+
if not resp.content:
|
|
181
|
+
return {}
|
|
182
|
+
raise ApprenticeError(
|
|
183
|
+
f"{method} {shown_path} -> {resp.status_code} returned a body that is not JSON: "
|
|
184
|
+
f"{resp.text[:200]}"
|
|
185
|
+
) from exc
|
|
118
186
|
|
|
119
187
|
def optimize(
|
|
120
188
|
self,
|
|
@@ -123,6 +191,7 @@ class Apprentice:
|
|
|
123
191
|
budget: Literal["quick", "thorough"] | None = None,
|
|
124
192
|
metric: str | None = None,
|
|
125
193
|
) -> Job:
|
|
194
|
+
"""Queue hosted optimization over gold and silver rows, returning its Job immediately."""
|
|
126
195
|
payload = {"task": task}
|
|
127
196
|
payload.update(
|
|
128
197
|
{
|
|
@@ -145,6 +214,7 @@ class Apprentice:
|
|
|
145
214
|
budget: Literal["quick", "thorough"] | None = None,
|
|
146
215
|
metric: str | None = None,
|
|
147
216
|
) -> OptimizationPreview:
|
|
217
|
+
"""Return metric guidance and cost estimates without queueing optimization."""
|
|
148
218
|
payload = {"task": task}
|
|
149
219
|
payload.update(
|
|
150
220
|
{
|
|
@@ -161,21 +231,26 @@ class Apprentice:
|
|
|
161
231
|
return OptimizationPreview.model_validate(data)
|
|
162
232
|
|
|
163
233
|
def job(self, job_id: str) -> Job:
|
|
234
|
+
"""Fetch current job information and return its Job proxy."""
|
|
164
235
|
data = self._request("GET", f"/v1/jobs/{job_id}")
|
|
165
236
|
return Job(self, JobInfo.model_validate(data))
|
|
166
237
|
|
|
167
238
|
def jobs(self, task: str) -> list[JobSummary]:
|
|
239
|
+
"""Return every hosted job for a task, newest first, whatever its kind."""
|
|
168
240
|
data = self._request("GET", f"/v1/tasks/{quote(task, safe='')}/jobs")
|
|
169
241
|
return [JobSummary.model_validate(item) for item in data]
|
|
170
242
|
|
|
171
243
|
def cancel_job(self, job_id: str) -> dict[str, Any]:
|
|
244
|
+
"""Request job cancellation and return the API response."""
|
|
172
245
|
return dict(self._request("POST", f"/v1/jobs/{job_id}/cancel"))
|
|
173
246
|
|
|
174
247
|
def local_runs(self, task: str) -> list[LocalRun]:
|
|
248
|
+
"""Return tracked local training runs for a task."""
|
|
175
249
|
data = self._request("GET", f"/v1/tasks/{quote(task, safe='')}/local-runs")
|
|
176
250
|
return [LocalRun.model_validate(item) for item in data]
|
|
177
251
|
|
|
178
252
|
def train(self, task: str) -> Job:
|
|
253
|
+
"""Queue hosted training over gold rows only, returning its Job immediately."""
|
|
179
254
|
data = self._request("POST", f"/v1/tasks/{quote(task, safe='')}/train", json={})
|
|
180
255
|
return Job(self, JobInfo.model_validate(data))
|
|
181
256
|
|
|
@@ -185,6 +260,7 @@ class Apprentice:
|
|
|
185
260
|
data: str | Path | None = None,
|
|
186
261
|
**options: Any,
|
|
187
262
|
) -> dict[str, Any]:
|
|
263
|
+
"""Optimize locally using a CSV or the task's gold and silver rows, returning results."""
|
|
188
264
|
from runapprentice.local import load_csv_rows, optimize_prompt_local
|
|
189
265
|
|
|
190
266
|
if data is None:
|
|
@@ -203,6 +279,7 @@ class Apprentice:
|
|
|
203
279
|
effort: str | None = None,
|
|
204
280
|
**options: Any,
|
|
205
281
|
) -> dict[str, Any]:
|
|
282
|
+
"""Train locally using a CSV or the task's gold rows, returning run results."""
|
|
206
283
|
from runapprentice.local import load_csv_rows, train_mlx_local
|
|
207
284
|
|
|
208
285
|
if data is None:
|
|
@@ -222,6 +299,7 @@ class Apprentice:
|
|
|
222
299
|
seed: int,
|
|
223
300
|
config: dict[str, Any] | None = None,
|
|
224
301
|
) -> LocalRun:
|
|
302
|
+
"""Register a local training run and return its tracking record."""
|
|
225
303
|
data = self._request(
|
|
226
304
|
"POST",
|
|
227
305
|
"/v1/local-runs",
|
|
@@ -236,6 +314,7 @@ class Apprentice:
|
|
|
236
314
|
return LocalRun.model_validate(data)
|
|
237
315
|
|
|
238
316
|
def complete_local_run(self, run_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
317
|
+
"""Submit a local run result and return the API response."""
|
|
239
318
|
return dict(self._request("POST", f"/v1/local-runs/{run_id}/complete", json=payload))
|
|
240
319
|
|
|
241
320
|
def feedback(
|
|
@@ -245,6 +324,7 @@ class Apprentice:
|
|
|
245
324
|
score: float | None = None,
|
|
246
325
|
note: str | None = None,
|
|
247
326
|
) -> None:
|
|
327
|
+
"""Flush pending captures, then submit feedback for a trace."""
|
|
248
328
|
if self._dispatcher is not None and not self._dispatcher.flush():
|
|
249
329
|
raise ApprenticeError(
|
|
250
330
|
"feedback not sent because pending captures did not finish before the flush timeout"
|
|
@@ -352,8 +432,8 @@ class Apprentice:
|
|
|
352
432
|
self._capture_warned = False
|
|
353
433
|
return record.trace_id
|
|
354
434
|
if resp.status_code >= 500:
|
|
355
|
-
raise ApprenticeError(
|
|
356
|
-
self._warn_capture_once(f"backend rejected trace: {resp.status_code}
|
|
435
|
+
raise ApprenticeError("backend 5xx for /v1/traces")
|
|
436
|
+
self._warn_capture_once(f"backend rejected trace: {resp.status_code} for /v1/traces")
|
|
357
437
|
return None
|
|
358
438
|
|
|
359
439
|
def _warn_capture_once(self, message: str) -> None:
|
|
@@ -368,6 +448,7 @@ class Apprentice:
|
|
|
368
448
|
logger.debug("runapprentice: %s", message)
|
|
369
449
|
|
|
370
450
|
def close(self) -> None:
|
|
451
|
+
"""Flush pending captures and close transport resources."""
|
|
371
452
|
if self._dispatcher is not None:
|
|
372
453
|
self._dispatcher.flush()
|
|
373
454
|
self._dispatcher.close()
|
|
@@ -387,11 +468,18 @@ class Job:
|
|
|
387
468
|
def status(self) -> str:
|
|
388
469
|
return self.info.status
|
|
389
470
|
|
|
471
|
+
@property
|
|
472
|
+
def error(self) -> str | None:
|
|
473
|
+
"""Return failure detail reported for the job, if any."""
|
|
474
|
+
return self.info.error
|
|
475
|
+
|
|
390
476
|
def refresh(self) -> Job:
|
|
477
|
+
"""Refresh this proxy in place and return it."""
|
|
391
478
|
self.info = self._client.job(self.info.job_id).info
|
|
392
479
|
return self
|
|
393
480
|
|
|
394
481
|
def wait(self, poll_seconds: float = 5.0, timeout_seconds: float = 3600.0) -> Job:
|
|
482
|
+
"""Poll until the job finishes; raise ApprenticeError on timeout and return this Job."""
|
|
395
483
|
deadline = time.monotonic() + timeout_seconds
|
|
396
484
|
while self.refresh().info.status in ("queued", "running"):
|
|
397
485
|
if time.monotonic() > deadline:
|
|
@@ -400,9 +488,11 @@ class Job:
|
|
|
400
488
|
return self
|
|
401
489
|
|
|
402
490
|
def cancel(self) -> dict[str, Any]:
|
|
491
|
+
"""Request cancellation and return the API response."""
|
|
403
492
|
return self._client.cancel_job(self.info.job_id)
|
|
404
493
|
|
|
405
494
|
def report(self) -> JobReport:
|
|
495
|
+
"""Return the finished job report, or raise ApprenticeError when unavailable."""
|
|
406
496
|
if self.info.report is None:
|
|
407
497
|
raise ApprenticeError(
|
|
408
498
|
f"job {self.info.job_id} has no report (status={self.info.status})"
|
|
@@ -27,8 +27,10 @@ from runapprentice.client import ApprenticeError
|
|
|
27
27
|
from runapprentice.models import DatasetRow
|
|
28
28
|
|
|
29
29
|
LOCAL_MODEL_ID = "mlx-community/Qwen3.5-4B-OptiQ-4bit"
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
# Mirrors the API defaults (owner decision 2026-07-31). The exact id matters: the bare
|
|
31
|
+
# `gpt-5.6` alias routes to Sol, not Luna.
|
|
32
|
+
DEFAULT_STUDENT_MODEL = "openai/gpt-5.6-luna"
|
|
33
|
+
DEFAULT_REFLECTION_MODEL = "openai/gpt-5.6-luna"
|
|
32
34
|
|
|
33
35
|
# Every tier trains the SAME model quality: 16 LoRA layers (mlx-lm's default;
|
|
34
36
|
# fewer layers reduces final quality per LORA.md) and the same number of passes
|
|
@@ -117,18 +119,64 @@ def score_json_field_f1(row: DatasetRow, actual: str) -> float:
|
|
|
117
119
|
actual_obj = json.loads(_extract_json_text(actual))
|
|
118
120
|
except json.JSONDecodeError:
|
|
119
121
|
return 0.0
|
|
120
|
-
|
|
122
|
+
# Objects AND arrays, matching the API scorer: a task whose gold answers are top-level
|
|
123
|
+
# arrays scored 0 here for a correct answer.
|
|
124
|
+
if not isinstance(expected_obj, (dict, list)) or not isinstance(actual_obj, (dict, list)):
|
|
121
125
|
return 0.0
|
|
122
126
|
expected = _json_leaf_pairs(expected_obj)
|
|
123
127
|
actual_pairs = _json_leaf_pairs(actual_obj)
|
|
128
|
+
# Mirrors the hosted ceiling. Without it the same answer scores 1.0 here and 0.0 hosted,
|
|
129
|
+
# so a local run and a hosted run disagree about quality on identical data.
|
|
130
|
+
if len(expected) > _MAX_JSON_METRIC_LEAVES or len(actual_pairs) > _MAX_JSON_METRIC_LEAVES:
|
|
131
|
+
return 0.0
|
|
124
132
|
f1 = _leaf_f1(expected, actual_pairs)
|
|
125
|
-
if len(expected_obj) == 1:
|
|
133
|
+
if isinstance(expected_obj, dict) and isinstance(actual_obj, dict) and len(expected_obj) == 1:
|
|
126
134
|
root_key, root_value = next(iter(expected_obj.items()))
|
|
127
135
|
if isinstance(root_value, dict) and root_key not in actual_obj:
|
|
128
136
|
f1 = max(f1, _leaf_f1(_json_leaf_pairs(root_value), actual_pairs))
|
|
129
137
|
return f1
|
|
130
138
|
|
|
131
139
|
|
|
140
|
+
def _reject_unscoreable_rows(rows: Sequence[DatasetRow]) -> None:
|
|
141
|
+
"""Stop before spending if the scorer cannot read this data.
|
|
142
|
+
|
|
143
|
+
Local optimize scores with `score_json_field_f1` and nothing else -- the CLI refuses
|
|
144
|
+
`--metric` in local mode. That scorer returns 0.0 for an answer it cannot parse as a JSON
|
|
145
|
+
object or array, so a dataset of plain labels ("yes"/"no") used to run to completion on the
|
|
146
|
+
user's own OpenAI key and report 0.0 to 0.0: money and minutes spent measuring nothing.
|
|
147
|
+
The same shape of bug reached production hosted-side on 2026-08-02.
|
|
148
|
+
|
|
149
|
+
The test mirrors the scorer's own accept rule rather than restating it, so the two cannot
|
|
150
|
+
drift apart.
|
|
151
|
+
"""
|
|
152
|
+
bad = [
|
|
153
|
+
index
|
|
154
|
+
for index, row in enumerate(rows, start=1)
|
|
155
|
+
if not _is_json_container(row.output)
|
|
156
|
+
]
|
|
157
|
+
if not bad:
|
|
158
|
+
return
|
|
159
|
+
shown = ", ".join(str(index) for index in bad[:5])
|
|
160
|
+
more = f" (and {len(bad) - 5} more)" if len(bad) > 5 else ""
|
|
161
|
+
# One action, and it is true on every path that reaches this message. An earlier draft
|
|
162
|
+
# also offered "run without --local", which is false advice here: the hosted branch of
|
|
163
|
+
# `_optimize` never reads --data, so dropping the flag silently ignores the CSV and
|
|
164
|
+
# optimizes whatever rows already exist server-side. The CLI has no upload command, so
|
|
165
|
+
# there is no hosted path for THIS file to name.
|
|
166
|
+
raise ApprenticeError(
|
|
167
|
+
f"{len(bad)} of {len(rows)} rows have an output that is not a JSON object or array, "
|
|
168
|
+
f"starting at row {shown}{more}. Local optimize scores JSON extraction only, so these "
|
|
169
|
+
"rows would all score zero. Give each row's output column a JSON object or array."
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _is_json_container(output: str) -> bool:
|
|
174
|
+
try:
|
|
175
|
+
return isinstance(json.loads(output), (dict, list))
|
|
176
|
+
except json.JSONDecodeError:
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
|
|
132
180
|
def optimize_prompt_local(
|
|
133
181
|
rows: Sequence[DatasetRow],
|
|
134
182
|
*,
|
|
@@ -141,6 +189,7 @@ def optimize_prompt_local(
|
|
|
141
189
|
raise ApprenticeError("OPENAI_API_KEY is required for local prompt optimization")
|
|
142
190
|
if len(rows) < 2:
|
|
143
191
|
raise ApprenticeError("local optimize needs at least two rows for train and held-out scoring")
|
|
192
|
+
_reject_unscoreable_rows(rows)
|
|
144
193
|
dspy = _load_dspy()
|
|
145
194
|
student_model = student_model or os.environ.get("APPRENTICE_STUDENT_MODEL", DEFAULT_STUDENT_MODEL)
|
|
146
195
|
reflection_model = reflection_model or os.environ.get(
|
|
@@ -525,51 +574,75 @@ def _generate(row: DatasetRow, adapter_dir: Path, model_id: str) -> str:
|
|
|
525
574
|
|
|
526
575
|
|
|
527
576
|
def _extract_json_text(text: str) -> str:
|
|
577
|
+
# Objects AND arrays, mirroring the API. Scanning for "{" alone returned the first inner
|
|
578
|
+
# OBJECT of a wrapped array, so an array answer was compared as an object and scored 0.
|
|
528
579
|
candidate = text.strip()
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
580
|
+
openers = [(candidate.find(o), o, c) for o, c in (("{", "}"), ("[", "]")) if candidate.find(o) != -1]
|
|
581
|
+
for _, opener, closer in sorted(openers):
|
|
582
|
+
start = candidate.find(opener)
|
|
583
|
+
while start != -1:
|
|
584
|
+
in_string = False
|
|
585
|
+
escaped = False
|
|
586
|
+
depth = 0
|
|
587
|
+
for i, char in enumerate(candidate[start:], start=start):
|
|
588
|
+
if in_string:
|
|
589
|
+
if escaped:
|
|
590
|
+
escaped = False
|
|
591
|
+
elif char == "\\":
|
|
592
|
+
escaped = True
|
|
593
|
+
elif char == '"':
|
|
594
|
+
in_string = False
|
|
595
|
+
continue
|
|
596
|
+
if char == '"':
|
|
597
|
+
in_string = True
|
|
598
|
+
elif char == opener:
|
|
599
|
+
depth += 1
|
|
600
|
+
elif char == closer:
|
|
601
|
+
depth -= 1
|
|
602
|
+
if depth == 0:
|
|
603
|
+
span = candidate[start : i + 1]
|
|
604
|
+
try:
|
|
605
|
+
json.loads(span)
|
|
606
|
+
return span
|
|
607
|
+
except json.JSONDecodeError:
|
|
608
|
+
break
|
|
609
|
+
start = candidate.find(opener, start + 1)
|
|
552
610
|
return candidate
|
|
553
611
|
|
|
554
612
|
|
|
555
|
-
|
|
556
|
-
|
|
613
|
+
# Leaf kinds, mirroring the API. The kind is a separate field so an empty container can
|
|
614
|
+
# never be confused with a scalar that spells one.
|
|
615
|
+
# Mirrors apprentice-api optimizer._MAX_JSON_METRIC_LEAVES.
|
|
616
|
+
_MAX_JSON_METRIC_LEAVES = 2000
|
|
617
|
+
_LEAF_SCALAR = "scalar"
|
|
618
|
+
_LEAF_EMPTY_OBJECT = "empty-object"
|
|
619
|
+
_LEAF_EMPTY_ARRAY = "empty-array"
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _json_leaf_pairs(value: Any, path: str = "") -> set[tuple[str, str, str]]:
|
|
623
|
+
leaves: set[tuple[str, str, str]] = set()
|
|
557
624
|
|
|
558
625
|
def walk(node: Any, current_path: str) -> None:
|
|
559
626
|
if isinstance(node, dict):
|
|
627
|
+
if not node:
|
|
628
|
+
leaves.add((current_path or "$", _LEAF_EMPTY_OBJECT, ""))
|
|
629
|
+
return
|
|
560
630
|
for key in sorted(node):
|
|
561
631
|
walk(node[key], f"{current_path}.{key}" if current_path else str(key))
|
|
562
632
|
elif isinstance(node, list):
|
|
633
|
+
if not node:
|
|
634
|
+
leaves.add((current_path or "$", _LEAF_EMPTY_ARRAY, ""))
|
|
635
|
+
return
|
|
563
636
|
for i, item in enumerate(node):
|
|
564
637
|
walk(item, f"{current_path}[{i}]")
|
|
565
638
|
else:
|
|
566
|
-
leaves.add((current_path or "$", _normalize_scalar(node)))
|
|
639
|
+
leaves.add((current_path or "$", _LEAF_SCALAR, _normalize_scalar(node)))
|
|
567
640
|
|
|
568
641
|
walk(value, path)
|
|
569
642
|
return leaves
|
|
570
643
|
|
|
571
644
|
|
|
572
|
-
def _leaf_f1(expected: set[tuple[str, str]], actual: set[tuple[str, str]]) -> float:
|
|
645
|
+
def _leaf_f1(expected: set[tuple[str, str, str]], actual: set[tuple[str, str, str]]) -> float:
|
|
573
646
|
tp = len(expected & actual)
|
|
574
647
|
precision = tp / len(actual) if actual else 0.0
|
|
575
648
|
recall = tp / len(expected) if expected else 0.0
|