runapprentice 0.2.1__tar.gz → 0.4.0__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 (25) hide show
  1. {runapprentice-0.2.1 → runapprentice-0.4.0}/.gitignore +17 -1
  2. {runapprentice-0.2.1 → runapprentice-0.4.0}/PKG-INFO +70 -7
  3. {runapprentice-0.2.1 → runapprentice-0.4.0}/README.md +69 -6
  4. {runapprentice-0.2.1 → runapprentice-0.4.0}/pyproject.toml +1 -1
  5. {runapprentice-0.2.1 → runapprentice-0.4.0}/src/runapprentice/__init__.py +8 -0
  6. {runapprentice-0.2.1 → runapprentice-0.4.0}/src/runapprentice/cli.py +85 -5
  7. {runapprentice-0.2.1 → runapprentice-0.4.0}/src/runapprentice/client.py +62 -6
  8. runapprentice-0.4.0/src/runapprentice/models.py +324 -0
  9. {runapprentice-0.2.1 → runapprentice-0.4.0}/src/runapprentice/openai.py +60 -15
  10. {runapprentice-0.2.1 → runapprentice-0.4.0}/tests/test_cli.py +39 -1
  11. runapprentice-0.4.0/tests/test_client.py +776 -0
  12. runapprentice-0.4.0/tests/test_openai.py +254 -0
  13. runapprentice-0.2.1/src/runapprentice/models.py +0 -81
  14. runapprentice-0.2.1/tests/test_client.py +0 -326
  15. runapprentice-0.2.1/tests/test_openai.py +0 -91
  16. {runapprentice-0.2.1 → runapprentice-0.4.0}/LICENSE +0 -0
  17. {runapprentice-0.2.1 → runapprentice-0.4.0}/src/runapprentice/_capture.py +0 -0
  18. {runapprentice-0.2.1 → runapprentice-0.4.0}/src/runapprentice/langchain.py +0 -0
  19. {runapprentice-0.2.1 → runapprentice-0.4.0}/src/runapprentice/local.py +0 -0
  20. {runapprentice-0.2.1 → runapprentice-0.4.0}/src/runapprentice/py.typed +0 -0
  21. {runapprentice-0.2.1 → runapprentice-0.4.0}/tests/test_capture.py +0 -0
  22. {runapprentice-0.2.1 → runapprentice-0.4.0}/tests/test_dispatcher.py +0 -0
  23. {runapprentice-0.2.1 → runapprentice-0.4.0}/tests/test_failopen.py +0 -0
  24. {runapprentice-0.2.1 → runapprentice-0.4.0}/tests/test_langchain_prompts.py +0 -0
  25. {runapprentice-0.2.1 → runapprentice-0.4.0}/tests/test_local.py +0 -0
@@ -34,5 +34,21 @@ optimized_program.json
34
34
  apprentice-ui/codex_working.md
35
35
  .codex/
36
36
 
37
- # Local dev databases
37
+ # Local dev databases (and their manual backups)
38
38
  *.db
39
+ *.db.bak*
40
+
41
+ # Local machine config with secrets — never commit
42
+ .mcp.json
43
+
44
+ # Local working artifacts, not repo content
45
+ # (.claude/skills holds symlinks into the ignored .agents/ dir — dangling on a fresh clone)
46
+ .claude/skills/
47
+ outputs/
48
+ test-sdk/
49
+ .agents/
50
+ skills-lock.json
51
+ docs/personal_learning.md
52
+ .uv-cache/
53
+ spike/Capture-*.png
54
+ apprentice-ui/ClaudeDesign/screenshots/localhost-*.png
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runapprentice
3
- Version: 0.2.1
3
+ Version: 0.4.0
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
@@ -31,7 +31,7 @@ Description-Content-Type: text/markdown
31
31
 
32
32
  The Python SDK for [Apprentice](https://runapprentice.com), a tool that cuts frontier-LLM cost
33
33
  on repeatable tasks. You give it a dataset of inputs and correct outputs for one task (uploaded
34
- as a CSV, or captured live from a LangChain app with one callback). Apprentice runs prompt
34
+ as a CSV, or captured live from an OpenAI or LangChain app). Apprentice runs prompt
35
35
  optimization (DSPy GEPA) against the verified rows (gold plus silver), or fine-tunes a small open model on
36
36
  your Mac, and reports the score change on a held-out slice, so the gain is measured, never
37
37
  claimed. Gated, eval-verified takeover of production traffic is still in development.
@@ -89,7 +89,54 @@ print(report.baseline_score, "->", report.optimized_score)
89
89
 
90
90
  # 4. Pull the optimized prompt back into your code (versioned)
91
91
  best = client.prompts.get("ticket-triage")
92
- print(best.version, best.text)
92
+ resp = OpenAI().chat.completions.create( # from openai import OpenAI
93
+ model="gpt-5-mini",
94
+ messages=best.messages(input=ticket_text),
95
+ )
96
+ ```
97
+
98
+ `datasets.upload()` replaces prior rows for this task where the source is an upload and
99
+ the tier is silver or raw. Gold rows and SDK-captured traces stay intact. Omitting `prompt`
100
+ or passing `None` clears the task's stored baseline prompt. Check
101
+ `DatasetStatus.replaced_rows` to see how many rows were removed.
102
+
103
+ `best.text` is instruction text, not a template. It holds no input placeholder and often
104
+ contains literal JSON braces, so `best.text.format(input=...)` raises `KeyError` and
105
+ `best.text.replace("{input}", ...)` drops the ticket with no error at all. `messages()`
106
+ renders the message shape the backend recorded when it scored this version. Match the rest
107
+ of the request too if you want the reported score to describe your call: JSON metrics were
108
+ scored with `response_format={"type": "json_object"}` (on the Responses API, that is
109
+ `text={"format": {"type": "json_object"}}`), on the model in
110
+ `report.detail["student_model"]`. Versions optimized before 0.4.0 carry no recorded shape
111
+ and fall back to the task's current baseline prompt. The keyword names are the artifact's `input_variables`:
112
+ `input` for a plain task, `question` and `context` for RAG, your own names if you
113
+ registered a template.
114
+
115
+ ## Live capture: OpenAI (one wrapper)
116
+
117
+ ```python
118
+ from openai import OpenAI
119
+ from runapprentice.openai import wrap
120
+
121
+ openai = wrap(OpenAI(), client, task="ticket-triage")
122
+
123
+ response = openai.responses.create(
124
+ model="gpt-5-mini",
125
+ input="Triage this ticket.",
126
+ )
127
+ # create() and parse() are captured, on chat.completions and responses alike
128
+ # (parse posts on its own, so patching create alone would miss it).
129
+ ```
130
+
131
+ Both sync `OpenAI` and async `AsyncOpenAI` clients work. Capture is fail-open and
132
+ returns every OpenAI result unchanged. Calls with `stream=True` and calls through
133
+ `responses.stream(...)` pass through uncaptured because reading them would consume
134
+ the caller's stream.
135
+
136
+ **PII redaction** runs in your process, before anything is transmitted:
137
+
138
+ ```python
139
+ openai = wrap(OpenAI(), client, task="ticket-triage", redact=lambda s: my_scrubber(s))
93
140
  ```
94
141
 
95
142
  ## Live capture: LangChain (one line, zero code changes)
@@ -226,17 +273,33 @@ count and the minimum).
226
273
  |---|---|---|
227
274
  | `Apprentice(api_key=, base_url=, timeout=)` | client | `ApprenticeError` on bad config |
228
275
  | `client.tasks.create(name, metric="auto")` | dict (`created` bool) | `ApprenticeError` on HTTP failure |
229
- | `client.datasets.upload(task, path= or rows=, input_col=, output_col=, prompt=)` | `DatasetStatus` | same; also if both/neither of path/rows |
276
+ | `client.datasets.upload(task, path= or rows=, input_col=, output_col=, prompt=)` | `DatasetStatus` including `replaced_rows` | same; also if both/neither of path/rows |
230
277
  | `client.datasets.status(task)` | `DatasetStatus(gold, silver, raw, ready_for_optimization)` | same |
231
278
  | `client.prompts.register(task, template)` | dict | `ImportError` if a LangChain template is passed without the `[langchain]` extra |
232
279
  | `client.prompts.to_langchain(task= or artifact=)` | LangChain prompt | `ImportError` without the `[langchain]` extra |
233
- | `client.optimize(task)` | `Job` | 400 if fewer than the backend's configured verified-row minimum (20 by default) |
280
+ | `client.optimize(task, baseline_prompt=None, budget=None, metric=None)` | `Job` | 400 if fewer than the backend's configured verified-row minimum (20 by default) |
281
+ | `client.preview_optimization(task, baseline_prompt=None, budget=None, metric=None)` | `OptimizationPreview` | `ApprenticeError` on API failure |
282
+ | `client.train(task)` | `Job` | 400 if fewer gold rows than the backend's training minimum |
234
283
  | `client.job(job_id)` / `job.refresh()` / `job.wait(poll_seconds=, timeout_seconds=)` | `Job` | `ApprenticeError` on timeout |
235
- | `job.report()` | `OptimizationReport(baseline_score, optimized_score, optimized_prompt, ...)` | if job has no report |
236
- | `client.prompts.get(task, version=None)` | `PromptVersion(version, text, score)` | 404 if never optimized |
284
+ | `client.cancel_job(job_id)` | dict (`cancel_requested`, `status`) | 409 if job is not cancellable |
285
+ | `job.cancel()` | dict (`cancel_requested`, `status`) | 409 if job is not cancellable |
286
+ | `client.jobs(task)` | `list[JobSummary]` | `ApprenticeError` on API failure |
287
+ | `client.local_runs(task)` | `list[LocalRun]` | `ApprenticeError` on API failure |
288
+ | `client.datasets.rows(task, tier, limit=200)` | `list[DatasetRow]` | `ApprenticeError` on API failure |
289
+ | `client.optimize_local(task, data=None, **options)` | dict (local GEPA result) | `ApprenticeError` if no `data` and no API key |
290
+ | `client.train_local(task, data=None, effort=None, **options)` | dict (local MLX result) | `ApprenticeError` if no `data` and no API key |
291
+ | `client.register_local_run(task, base_model, dataset_row_count, seed, config=None)` | `LocalRun` | `ApprenticeError` on API failure |
292
+ | `client.complete_local_run(run_id, payload)` | dict | `ApprenticeError` on API failure |
293
+ | `client.capture(task, output, input= or inputs=, model=, latency_ms=, ...)` | trace_id or None | **never raises** |
294
+ | `client.flush_captures(timeout=2.0)` | None (blocks until buffered traces deliver or timeout; no-op in sync mode) | nothing by design |
295
+ | `client.close()` | None (flushes and closes transport) | nothing by design |
296
+ | `job.report()` | typed optimize, train, or generate report; raw dict for future shapes | if job has no report |
297
+ | `client.prompts.get(task, version=None)` | `PromptVersion(version, text, score, baseline_prompt)` | 404 if never optimized |
298
+ | `PromptVersion.messages(user_prompt=None, **inputs)` | `list[dict]` reproducing the scored call | `ValueError` if a keyword is not one of the artifact's `input_variables` |
237
299
  | `client.prompts.history(task)` | `list[PromptVersion]` | `ApprenticeError` on API failure |
238
300
  | `client.feedback(trace_id, good=, score=, note=)` | None | 404 unknown trace; `ApprenticeError` if pending captures cannot drain in time, or on API failure |
239
301
  | `client.post_trace_failopen(record)` | trace_id or None | **never raises** |
302
+ | `wrap(openai_client, client, task, redact=None)` | the same OpenAI client, with chat completions and responses captured | **never raises from capture; OpenAI call errors pass through unchanged** |
240
303
  | `ApprenticeCallback(task, client, redact=None)` | LangChain callback | **never raises into your chain** |
241
304
 
242
305
  ## Data tiers (how your rows are treated)
@@ -2,7 +2,7 @@
2
2
 
3
3
  The Python SDK for [Apprentice](https://runapprentice.com), a tool that cuts frontier-LLM cost
4
4
  on repeatable tasks. You give it a dataset of inputs and correct outputs for one task (uploaded
5
- as a CSV, or captured live from a LangChain app with one callback). Apprentice runs prompt
5
+ as a CSV, or captured live from an OpenAI or LangChain app). Apprentice runs prompt
6
6
  optimization (DSPy GEPA) against the verified rows (gold plus silver), or fine-tunes a small open model on
7
7
  your Mac, and reports the score change on a held-out slice, so the gain is measured, never
8
8
  claimed. Gated, eval-verified takeover of production traffic is still in development.
@@ -60,7 +60,54 @@ print(report.baseline_score, "->", report.optimized_score)
60
60
 
61
61
  # 4. Pull the optimized prompt back into your code (versioned)
62
62
  best = client.prompts.get("ticket-triage")
63
- print(best.version, best.text)
63
+ resp = OpenAI().chat.completions.create( # from openai import OpenAI
64
+ model="gpt-5-mini",
65
+ messages=best.messages(input=ticket_text),
66
+ )
67
+ ```
68
+
69
+ `datasets.upload()` replaces prior rows for this task where the source is an upload and
70
+ the tier is silver or raw. Gold rows and SDK-captured traces stay intact. Omitting `prompt`
71
+ or passing `None` clears the task's stored baseline prompt. Check
72
+ `DatasetStatus.replaced_rows` to see how many rows were removed.
73
+
74
+ `best.text` is instruction text, not a template. It holds no input placeholder and often
75
+ contains literal JSON braces, so `best.text.format(input=...)` raises `KeyError` and
76
+ `best.text.replace("{input}", ...)` drops the ticket with no error at all. `messages()`
77
+ renders the message shape the backend recorded when it scored this version. Match the rest
78
+ of the request too if you want the reported score to describe your call: JSON metrics were
79
+ scored with `response_format={"type": "json_object"}` (on the Responses API, that is
80
+ `text={"format": {"type": "json_object"}}`), on the model in
81
+ `report.detail["student_model"]`. Versions optimized before 0.4.0 carry no recorded shape
82
+ and fall back to the task's current baseline prompt. The keyword names are the artifact's `input_variables`:
83
+ `input` for a plain task, `question` and `context` for RAG, your own names if you
84
+ registered a template.
85
+
86
+ ## Live capture: OpenAI (one wrapper)
87
+
88
+ ```python
89
+ from openai import OpenAI
90
+ from runapprentice.openai import wrap
91
+
92
+ openai = wrap(OpenAI(), client, task="ticket-triage")
93
+
94
+ response = openai.responses.create(
95
+ model="gpt-5-mini",
96
+ input="Triage this ticket.",
97
+ )
98
+ # create() and parse() are captured, on chat.completions and responses alike
99
+ # (parse posts on its own, so patching create alone would miss it).
100
+ ```
101
+
102
+ Both sync `OpenAI` and async `AsyncOpenAI` clients work. Capture is fail-open and
103
+ returns every OpenAI result unchanged. Calls with `stream=True` and calls through
104
+ `responses.stream(...)` pass through uncaptured because reading them would consume
105
+ the caller's stream.
106
+
107
+ **PII redaction** runs in your process, before anything is transmitted:
108
+
109
+ ```python
110
+ openai = wrap(OpenAI(), client, task="ticket-triage", redact=lambda s: my_scrubber(s))
64
111
  ```
65
112
 
66
113
  ## Live capture: LangChain (one line, zero code changes)
@@ -197,17 +244,33 @@ count and the minimum).
197
244
  |---|---|---|
198
245
  | `Apprentice(api_key=, base_url=, timeout=)` | client | `ApprenticeError` on bad config |
199
246
  | `client.tasks.create(name, metric="auto")` | dict (`created` bool) | `ApprenticeError` on HTTP failure |
200
- | `client.datasets.upload(task, path= or rows=, input_col=, output_col=, prompt=)` | `DatasetStatus` | same; also if both/neither of path/rows |
247
+ | `client.datasets.upload(task, path= or rows=, input_col=, output_col=, prompt=)` | `DatasetStatus` including `replaced_rows` | same; also if both/neither of path/rows |
201
248
  | `client.datasets.status(task)` | `DatasetStatus(gold, silver, raw, ready_for_optimization)` | same |
202
249
  | `client.prompts.register(task, template)` | dict | `ImportError` if a LangChain template is passed without the `[langchain]` extra |
203
250
  | `client.prompts.to_langchain(task= or artifact=)` | LangChain prompt | `ImportError` without the `[langchain]` extra |
204
- | `client.optimize(task)` | `Job` | 400 if fewer than the backend's configured verified-row minimum (20 by default) |
251
+ | `client.optimize(task, baseline_prompt=None, budget=None, metric=None)` | `Job` | 400 if fewer than the backend's configured verified-row minimum (20 by default) |
252
+ | `client.preview_optimization(task, baseline_prompt=None, budget=None, metric=None)` | `OptimizationPreview` | `ApprenticeError` on API failure |
253
+ | `client.train(task)` | `Job` | 400 if fewer gold rows than the backend's training minimum |
205
254
  | `client.job(job_id)` / `job.refresh()` / `job.wait(poll_seconds=, timeout_seconds=)` | `Job` | `ApprenticeError` on timeout |
206
- | `job.report()` | `OptimizationReport(baseline_score, optimized_score, optimized_prompt, ...)` | if job has no report |
207
- | `client.prompts.get(task, version=None)` | `PromptVersion(version, text, score)` | 404 if never optimized |
255
+ | `client.cancel_job(job_id)` | dict (`cancel_requested`, `status`) | 409 if job is not cancellable |
256
+ | `job.cancel()` | dict (`cancel_requested`, `status`) | 409 if job is not cancellable |
257
+ | `client.jobs(task)` | `list[JobSummary]` | `ApprenticeError` on API failure |
258
+ | `client.local_runs(task)` | `list[LocalRun]` | `ApprenticeError` on API failure |
259
+ | `client.datasets.rows(task, tier, limit=200)` | `list[DatasetRow]` | `ApprenticeError` on API failure |
260
+ | `client.optimize_local(task, data=None, **options)` | dict (local GEPA result) | `ApprenticeError` if no `data` and no API key |
261
+ | `client.train_local(task, data=None, effort=None, **options)` | dict (local MLX result) | `ApprenticeError` if no `data` and no API key |
262
+ | `client.register_local_run(task, base_model, dataset_row_count, seed, config=None)` | `LocalRun` | `ApprenticeError` on API failure |
263
+ | `client.complete_local_run(run_id, payload)` | dict | `ApprenticeError` on API failure |
264
+ | `client.capture(task, output, input= or inputs=, model=, latency_ms=, ...)` | trace_id or None | **never raises** |
265
+ | `client.flush_captures(timeout=2.0)` | None (blocks until buffered traces deliver or timeout; no-op in sync mode) | nothing by design |
266
+ | `client.close()` | None (flushes and closes transport) | nothing by design |
267
+ | `job.report()` | typed optimize, train, or generate report; raw dict for future shapes | if job has no report |
268
+ | `client.prompts.get(task, version=None)` | `PromptVersion(version, text, score, baseline_prompt)` | 404 if never optimized |
269
+ | `PromptVersion.messages(user_prompt=None, **inputs)` | `list[dict]` reproducing the scored call | `ValueError` if a keyword is not one of the artifact's `input_variables` |
208
270
  | `client.prompts.history(task)` | `list[PromptVersion]` | `ApprenticeError` on API failure |
209
271
  | `client.feedback(trace_id, good=, score=, note=)` | None | 404 unknown trace; `ApprenticeError` if pending captures cannot drain in time, or on API failure |
210
272
  | `client.post_trace_failopen(record)` | trace_id or None | **never raises** |
273
+ | `wrap(openai_client, client, task, redact=None)` | the same OpenAI client, with chat completions and responses captured | **never raises from capture; OpenAI call errors pass through unchanged** |
211
274
  | `ApprenticeCallback(task, client, redact=None)` | LangChain callback | **never raises into your chain** |
212
275
 
213
276
  ## Data tiers (how your rows are treated)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "runapprentice"
3
- version = "0.2.1"
3
+ version = "0.4.0"
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"
@@ -4,12 +4,16 @@ from runapprentice.client import Apprentice, ApprenticeError, Job, enable_debug_
4
4
  from runapprentice.models import (
5
5
  DatasetRow,
6
6
  DatasetStatus,
7
+ GenerationReport,
7
8
  JobInfo,
9
+ JobReport,
8
10
  JobSummary,
9
11
  LocalRun,
10
12
  OptimizationReport,
13
+ OptimizationPreview,
11
14
  PromptVersion,
12
15
  TraceRecord,
16
+ TrainingReport,
13
17
  )
14
18
 
15
19
  __all__ = [
@@ -19,12 +23,16 @@ __all__ = [
19
23
  "Job",
20
24
  "DatasetRow",
21
25
  "DatasetStatus",
26
+ "GenerationReport",
22
27
  "JobInfo",
28
+ "JobReport",
23
29
  "JobSummary",
24
30
  "LocalRun",
25
31
  "OptimizationReport",
32
+ "OptimizationPreview",
26
33
  "PromptVersion",
27
34
  "TraceRecord",
35
+ "TrainingReport",
28
36
  ]
29
37
 
30
38
  # Single source of truth is pyproject.toml; read it from package metadata so
@@ -13,7 +13,13 @@ from typing import Any
13
13
 
14
14
  from runapprentice.client import Apprentice, ApprenticeError
15
15
  from runapprentice import local as _local
16
- from runapprentice.models import JobInfo, JobSummary, OptimizationReport
16
+ from runapprentice.models import (
17
+ JobInfo,
18
+ JobReport,
19
+ JobSummary,
20
+ OptimizationPreview,
21
+ OptimizationReport,
22
+ )
17
23
 
18
24
  CONFIG_DIR = Path("~/.apprentice").expanduser()
19
25
  CONFIG_PATH = CONFIG_DIR / "config.json"
@@ -107,6 +113,22 @@ def build_parser() -> argparse.ArgumentParser:
107
113
  type=Path,
108
114
  help="CSV with input,output columns; required for --local unless an API key is set",
109
115
  )
116
+ optimize.add_argument(
117
+ "--budget",
118
+ choices=("quick", "thorough"),
119
+ help="hosted optimization budget; omit for balanced default",
120
+ )
121
+ optimize.add_argument("--metric", help="metric used to score this hosted run")
122
+ optimize.add_argument(
123
+ "--prompt-file",
124
+ type=Path,
125
+ help="read hosted run baseline prompt from this UTF-8 text file",
126
+ )
127
+ optimize.add_argument(
128
+ "--preview",
129
+ action="store_true",
130
+ help="show hosted metric and cost estimate without enqueueing a run",
131
+ )
110
132
  optimize.set_defaults(func=_optimize)
111
133
 
112
134
  train = sub.add_parser(
@@ -153,6 +175,10 @@ def build_parser() -> argparse.ArgumentParser:
153
175
  job = sub.add_parser("job", help="details for one hosted job")
154
176
  job.add_argument("job_id", help="job id from `apprentice jobs`")
155
177
  job.set_defaults(func=_job)
178
+
179
+ cancel = sub.add_parser("cancel", help="cancel a queued or running hosted job")
180
+ cancel.add_argument("job_id", help="job id from `apprentice jobs`")
181
+ cancel.set_defaults(func=_cancel)
156
182
  return parser
157
183
 
158
184
 
@@ -180,6 +206,11 @@ def _status(args: argparse.Namespace, client: Apprentice) -> int:
180
206
 
181
207
  def _optimize(args: argparse.Namespace, client: Apprentice) -> int:
182
208
  if args.local:
209
+ if args.budget or args.metric or args.prompt_file or args.preview:
210
+ return _fail(
211
+ "--budget, --metric, --prompt-file, and --preview apply to hosted optimization",
212
+ bool(args.json),
213
+ )
183
214
  return _optimize_local(
184
215
  args.task,
185
216
  args.data,
@@ -187,8 +218,39 @@ def _optimize(args: argparse.Namespace, client: Apprentice) -> int:
187
218
  bool(args.json),
188
219
  bool(args._api_key_present),
189
220
  )
190
- job = client.optimize(args.task).wait()
221
+ baseline_prompt = None
222
+ if args.prompt_file is not None:
223
+ try:
224
+ baseline_prompt = args.prompt_file.read_text(encoding="utf-8")
225
+ except OSError as exc:
226
+ raise ApprenticeError(f"could not read prompt file {args.prompt_file}: {exc}") from exc
227
+ if args.preview:
228
+ preview = client.preview_optimization(
229
+ args.task,
230
+ baseline_prompt=baseline_prompt,
231
+ budget=args.budget,
232
+ metric=args.metric,
233
+ )
234
+ return _emit(
235
+ preview.model_dump(mode="json"),
236
+ _preview_line(preview),
237
+ bool(args.json),
238
+ )
239
+ job = client.optimize(
240
+ args.task,
241
+ baseline_prompt=baseline_prompt,
242
+ budget=args.budget,
243
+ metric=args.metric,
244
+ ).wait()
191
245
  payload = job.info.model_dump(mode="json")
246
+ if job.info.status != "succeeded":
247
+ # Exit nonzero, or a caller that checks the exit code (every script and
248
+ # every agent) reads a failed run as a shipped one.
249
+ return _fail(
250
+ f"optimize {job.info.status}: {job.info.error or 'no error reported'}",
251
+ bool(args.json),
252
+ payload=payload,
253
+ )
192
254
  return _emit(payload, _report_line(job.info.report), bool(args.json))
193
255
 
194
256
 
@@ -222,6 +284,11 @@ def _job(args: argparse.Namespace, client: Apprentice) -> int:
222
284
  return _emit(job.info.model_dump(mode="json"), _job_info_line(job.info), bool(args.json))
223
285
 
224
286
 
287
+ def _cancel(args: argparse.Namespace, client: Apprentice) -> int:
288
+ result = client.cancel_job(args.job_id)
289
+ return _emit(result, f"cancel requested: {args.job_id} ({result['status']})", bool(args.json))
290
+
291
+
225
292
  def _train_local(
226
293
  task: str,
227
294
  client: Apprentice,
@@ -424,20 +491,33 @@ def _job_info_line(job: JobInfo) -> str:
424
491
  return f"{job.job_id} {job.kind} {job.status}"
425
492
 
426
493
 
427
- def _report_line(report: OptimizationReport | None) -> str:
494
+ def _report_line(report: JobReport | None) -> str:
428
495
  if report is None:
429
496
  return "optimize finished with no report"
497
+ if not isinstance(report, OptimizationReport):
498
+ return "optimize finished with an unrecognized report"
430
499
  return f"optimize done: baseline={report.baseline_score} optimized={report.optimized_score}"
431
500
 
432
501
 
502
+ def _preview_line(preview: OptimizationPreview) -> str:
503
+ estimate = preview.estimate
504
+ return (
505
+ f"optimize preview: metric={preview.eval_metric} budget={estimate.get('budget')} "
506
+ f"rows={estimate.get('rows')} cost_usd={estimate.get('cost_usd_low')}"
507
+ f"-{estimate.get('cost_usd_high')}"
508
+ )
509
+
510
+
433
511
  def _emit(payload: dict[str, Any], text: str, as_json: bool) -> int:
434
512
  print(json.dumps(payload, sort_keys=True) if as_json else text)
435
513
  return 0
436
514
 
437
515
 
438
- def _fail(message: str, as_json: bool) -> int:
516
+ def _fail(message: str, as_json: bool, payload: dict[str, Any] | None = None) -> int:
439
517
  if as_json:
440
- print(json.dumps({"error": message}, sort_keys=True), file=sys.stderr)
518
+ # Keep the job fields alongside the error so a caller parsing --json still
519
+ # gets the job id and status it needs to investigate.
520
+ print(json.dumps({**(payload or {}), "error": message}, sort_keys=True), file=sys.stderr)
441
521
  else:
442
522
  print(message, file=sys.stderr)
443
523
  return 1
@@ -16,7 +16,7 @@ import os
16
16
  import time
17
17
  import uuid
18
18
  from pathlib import Path
19
- from typing import Any
19
+ from typing import Any, Literal
20
20
 
21
21
  import httpx
22
22
 
@@ -25,9 +25,10 @@ from runapprentice.models import (
25
25
  DatasetRow,
26
26
  DatasetStatus,
27
27
  JobInfo,
28
+ JobReport,
28
29
  JobSummary,
29
30
  LocalRun,
30
- OptimizationReport,
31
+ OptimizationPreview,
31
32
  PromptVersion,
32
33
  TraceRecord,
33
34
  )
@@ -114,10 +115,50 @@ class Apprentice:
114
115
  raise ApprenticeError(f"{method} {path} -> {resp.status_code}: {resp.text[:500]}{suffix}")
115
116
  return resp.json()
116
117
 
117
- def optimize(self, task: str) -> Job:
118
- data = self._request("POST", "/v1/optimize", json={"task": task})
118
+ def optimize(
119
+ self,
120
+ task: str,
121
+ baseline_prompt: str | None = None,
122
+ budget: Literal["quick", "thorough"] | None = None,
123
+ metric: str | None = None,
124
+ ) -> Job:
125
+ payload = {"task": task}
126
+ payload.update(
127
+ {
128
+ key: value
129
+ for key, value in {
130
+ "baseline_prompt": baseline_prompt,
131
+ "budget": budget,
132
+ "metric": metric,
133
+ }.items()
134
+ if value is not None
135
+ }
136
+ )
137
+ data = self._request("POST", "/v1/optimize", json=payload)
119
138
  return Job(self, JobInfo.model_validate(data))
120
139
 
140
+ def preview_optimization(
141
+ self,
142
+ task: str,
143
+ baseline_prompt: str | None = None,
144
+ budget: Literal["quick", "thorough"] | None = None,
145
+ metric: str | None = None,
146
+ ) -> OptimizationPreview:
147
+ payload = {"task": task}
148
+ payload.update(
149
+ {
150
+ key: value
151
+ for key, value in {
152
+ "baseline_prompt": baseline_prompt,
153
+ "budget": budget,
154
+ "metric": metric,
155
+ }.items()
156
+ if value is not None
157
+ }
158
+ )
159
+ data = self._request("POST", "/v1/optimize/preview", json=payload)
160
+ return OptimizationPreview.model_validate(data)
161
+
121
162
  def job(self, job_id: str) -> Job:
122
163
  data = self._request("GET", f"/v1/jobs/{job_id}")
123
164
  return Job(self, JobInfo.model_validate(data))
@@ -126,6 +167,13 @@ class Apprentice:
126
167
  data = self._request("GET", f"/v1/tasks/{task}/jobs")
127
168
  return [JobSummary.model_validate(item) for item in data]
128
169
 
170
+ def cancel_job(self, job_id: str) -> dict[str, Any]:
171
+ return dict(self._request("POST", f"/v1/jobs/{job_id}/cancel"))
172
+
173
+ def local_runs(self, task: str) -> list[LocalRun]:
174
+ data = self._request("GET", f"/v1/tasks/{task}/local-runs")
175
+ return [LocalRun.model_validate(item) for item in data]
176
+
129
177
  def train(self, task: str) -> Job:
130
178
  data = self._request("POST", f"/v1/tasks/{task}/train", json={})
131
179
  return Job(self, JobInfo.model_validate(data))
@@ -350,7 +398,10 @@ class Job:
350
398
  time.sleep(poll_seconds)
351
399
  return self
352
400
 
353
- def report(self) -> OptimizationReport:
401
+ def cancel(self) -> dict[str, Any]:
402
+ return self._client.cancel_job(self.info.job_id)
403
+
404
+ def report(self) -> JobReport:
354
405
  if self.info.report is None:
355
406
  raise ApprenticeError(
356
407
  f"job {self.info.job_id} has no report (status={self.info.status})"
@@ -371,10 +422,15 @@ class _DatasetsAPI:
371
422
  output_col: str = "output",
372
423
  prompt: str | None = None,
373
424
  ) -> DatasetStatus:
374
- """Upload rows.
425
+ """Replace uploaded, unverified rows for a task.
375
426
 
376
427
  Rows may use legacy `input`/`output` or structured `inputs`/`output`.
377
428
  LCEL auto-capture of prompt variables is future work; v1 stays explicit.
429
+
430
+ This is a replace, not an append: existing uploaded silver and raw rows are
431
+ removed first, and `replaced_rows` in the response reports how many. Gold
432
+ rows and captured traces survive. Omitting `prompt` (or passing `None`)
433
+ clears the task's stored baseline prompt.
378
434
  """
379
435
  if (path is None) == (rows is None):
380
436
  raise ApprenticeError("provide exactly one of path= or rows=")