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.
Files changed (22) hide show
  1. {runapprentice-0.4.1 → runapprentice-0.4.3}/.gitignore +1 -0
  2. {runapprentice-0.4.1 → runapprentice-0.4.3}/PKG-INFO +5 -5
  3. {runapprentice-0.4.1 → runapprentice-0.4.3}/README.md +4 -4
  4. {runapprentice-0.4.1 → runapprentice-0.4.3}/pyproject.toml +1 -1
  5. {runapprentice-0.4.1 → runapprentice-0.4.3}/src/runapprentice/cli.py +101 -10
  6. {runapprentice-0.4.1 → runapprentice-0.4.3}/src/runapprentice/client.py +99 -9
  7. {runapprentice-0.4.1 → runapprentice-0.4.3}/src/runapprentice/local.py +104 -31
  8. {runapprentice-0.4.1 → runapprentice-0.4.3}/src/runapprentice/models.py +16 -0
  9. {runapprentice-0.4.1 → runapprentice-0.4.3}/tests/test_cli.py +306 -3
  10. {runapprentice-0.4.1 → runapprentice-0.4.3}/tests/test_client.py +294 -1
  11. {runapprentice-0.4.1 → runapprentice-0.4.3}/LICENSE +0 -0
  12. {runapprentice-0.4.1 → runapprentice-0.4.3}/src/runapprentice/__init__.py +0 -0
  13. {runapprentice-0.4.1 → runapprentice-0.4.3}/src/runapprentice/_capture.py +0 -0
  14. {runapprentice-0.4.1 → runapprentice-0.4.3}/src/runapprentice/langchain.py +0 -0
  15. {runapprentice-0.4.1 → runapprentice-0.4.3}/src/runapprentice/openai.py +0 -0
  16. {runapprentice-0.4.1 → runapprentice-0.4.3}/src/runapprentice/py.typed +0 -0
  17. {runapprentice-0.4.1 → runapprentice-0.4.3}/tests/test_capture.py +0 -0
  18. {runapprentice-0.4.1 → runapprentice-0.4.3}/tests/test_dispatcher.py +0 -0
  19. {runapprentice-0.4.1 → runapprentice-0.4.3}/tests/test_failopen.py +0 -0
  20. {runapprentice-0.4.1 → runapprentice-0.4.3}/tests/test_langchain_prompts.py +0 -0
  21. {runapprentice-0.4.1 → runapprentice-0.4.3}/tests/test_local.py +0 -0
  22. {runapprentice-0.4.1 → runapprentice-0.4.3}/tests/test_openai.py +0 -0
@@ -52,3 +52,4 @@ docs/personal_learning.md
52
52
  .uv-cache/
53
53
  spike/Capture-*.png
54
54
  apprentice-ui/ClaudeDesign/screenshots/localhost-*.png
55
+ supabase-magic-link-template.html
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runapprentice
3
- Version: 0.4.1
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 on a CSV alone, no Apprentice account. No golden dataset yet? `apprentice
54
- login` also lets Apprentice generate a starter set and route rows to a subject-matter expert
55
- for review, and tracks every run in the console. `apprentice --help` (or any
56
- subcommand `--help`) documents every flag; `--json` gives clean machine-readable output.
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 on a CSV alone, no Apprentice account. No golden dataset yet? `apprentice
25
- login` also lets Apprentice generate a starter set and route rows to a subject-matter expert
26
- for review, and tracks every run in the console. `apprentice --help` (or any
27
- subcommand `--help`) documents every flag; `--json` gives clean machine-readable output.
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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "runapprentice"
3
- version = "0.4.1"
3
+ version = "0.4.3"
4
4
  description = "Cut LLM cost without losing quality: capture traffic, build golden datasets, and optimize prompts."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -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
- api_key = _api_key()
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="run GEPA on this machine with your own OPENAI_API_KEY, no Apprentice account needed",
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 optimize/train jobs for a task, newest first")
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 = getpass.getpass("Apprentice API key: ").strip()
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
- CONFIG_DIR.mkdir(parents=True, exist_ok=True)
190
- CONFIG_PATH.write_text(json.dumps({"api_key": key}) + "\n", encoding="utf-8")
191
- CONFIG_PATH.chmod(stat.S_IRUSR | stat.S_IWUSR)
192
- return _emit({"config": str(CONFIG_PATH), "mode": "600"}, "Saved API key.", bool(args.json))
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
- data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
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
- return key if isinstance(key, str) else None
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, path, _ms(t0), exc)
152
+ logger.error("%s %s failed after %.0fms: %s", method, shown_path, _ms(t0), exc)
104
153
  raise ApprenticeError(
105
- f"{method} {path} failed: {exc}. "
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, path, resp.status_code, _ms(t0))
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("%s %s -> %s: %s", method, path, resp.status_code, resp.text[:500])
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
- ". Get a free Apprentice API key at https://runapprentice.com"
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(f"{method} {path} -> {resp.status_code}: {resp.text[:500]}{suffix}")
117
- return resp.json()
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(f"backend {resp.status_code}: {resp.text[:200]}")
356
- self._warn_capture_once(f"backend rejected trace: {resp.status_code} {resp.text[:200]}")
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
- DEFAULT_STUDENT_MODEL = "openai/gpt-5-mini"
31
- DEFAULT_REFLECTION_MODEL = "openai/gpt-5.4"
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
- if not isinstance(expected_obj, dict) or not isinstance(actual_obj, dict):
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
- start = candidate.find("{")
530
- while start != -1:
531
- in_string = False
532
- escaped = False
533
- depth = 0
534
- for i, char in enumerate(candidate[start:], start=start):
535
- if in_string:
536
- if escaped:
537
- escaped = False
538
- elif char == "\\":
539
- escaped = True
540
- elif char == '"':
541
- in_string = False
542
- continue
543
- if char == '"':
544
- in_string = True
545
- elif char == "{":
546
- depth += 1
547
- elif char == "}":
548
- depth -= 1
549
- if depth == 0:
550
- return candidate[start : i + 1]
551
- start = candidate.find("{", start + 1)
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
- def _json_leaf_pairs(value: Any, path: str = "") -> set[tuple[str, str]]:
556
- leaves: set[tuple[str, str]] = set()
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