runapprentice 0.4.0__tar.gz → 0.4.2__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.0 → runapprentice-0.4.2}/PKG-INFO +5 -5
- {runapprentice-0.4.0 → runapprentice-0.4.2}/README.md +4 -4
- {runapprentice-0.4.0 → runapprentice-0.4.2}/pyproject.toml +1 -1
- {runapprentice-0.4.0 → runapprentice-0.4.2}/src/runapprentice/cli.py +97 -9
- {runapprentice-0.4.0 → runapprentice-0.4.2}/src/runapprentice/client.py +106 -17
- {runapprentice-0.4.0 → runapprentice-0.4.2}/src/runapprentice/models.py +3 -0
- {runapprentice-0.4.0 → runapprentice-0.4.2}/src/runapprentice/openai.py +12 -7
- {runapprentice-0.4.0 → runapprentice-0.4.2}/tests/test_cli.py +305 -3
- {runapprentice-0.4.0 → runapprentice-0.4.2}/tests/test_client.py +192 -1
- {runapprentice-0.4.0 → runapprentice-0.4.2}/tests/test_openai.py +36 -2
- {runapprentice-0.4.0 → runapprentice-0.4.2}/.gitignore +0 -0
- {runapprentice-0.4.0 → runapprentice-0.4.2}/LICENSE +0 -0
- {runapprentice-0.4.0 → runapprentice-0.4.2}/src/runapprentice/__init__.py +0 -0
- {runapprentice-0.4.0 → runapprentice-0.4.2}/src/runapprentice/_capture.py +0 -0
- {runapprentice-0.4.0 → runapprentice-0.4.2}/src/runapprentice/langchain.py +0 -0
- {runapprentice-0.4.0 → runapprentice-0.4.2}/src/runapprentice/local.py +0 -0
- {runapprentice-0.4.0 → runapprentice-0.4.2}/src/runapprentice/py.typed +0 -0
- {runapprentice-0.4.0 → runapprentice-0.4.2}/tests/test_capture.py +0 -0
- {runapprentice-0.4.0 → runapprentice-0.4.2}/tests/test_dispatcher.py +0 -0
- {runapprentice-0.4.0 → runapprentice-0.4.2}/tests/test_failopen.py +0 -0
- {runapprentice-0.4.0 → runapprentice-0.4.2}/tests/test_langchain_prompts.py +0 -0
- {runapprentice-0.4.0 → runapprentice-0.4.2}/tests/test_local.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.2
|
|
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
|
|
@@ -168,7 +174,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
168
174
|
)
|
|
169
175
|
train.set_defaults(func=_train)
|
|
170
176
|
|
|
171
|
-
jobs = sub.add_parser("jobs", help="list hosted
|
|
177
|
+
jobs = sub.add_parser("jobs", help="list hosted jobs for a task, newest first")
|
|
172
178
|
jobs.add_argument("task", help="task name")
|
|
173
179
|
jobs.set_defaults(func=_jobs)
|
|
174
180
|
|
|
@@ -183,13 +189,70 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
183
189
|
|
|
184
190
|
|
|
185
191
|
def _login(args: argparse.Namespace) -> int:
|
|
186
|
-
key =
|
|
192
|
+
key = (
|
|
193
|
+
getpass.getpass("Apprentice API key: ") if sys.stdin.isatty() else sys.stdin.read()
|
|
194
|
+
).strip()
|
|
187
195
|
if not key:
|
|
188
196
|
return _fail("API key required", bool(args.json))
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
197
|
+
# The client rejects a key that cannot become an HTTP header (whitespace from a
|
|
198
|
+
# two-line paste, a smart quote). Letting it raise keeps one definition of what
|
|
199
|
+
# a usable key is, shared with the constructor and APPRENTICE_API_KEY.
|
|
200
|
+
# No captures happen during login, so skip the background dispatcher thread.
|
|
201
|
+
try:
|
|
202
|
+
client = Apprentice(api_key=key, capture_async=False)
|
|
203
|
+
except ApprenticeError as exc:
|
|
204
|
+
return _fail(f"{exc}, so it was not saved.", bool(args.json))
|
|
205
|
+
try:
|
|
206
|
+
response = client._http.get("/v1/tasks")
|
|
207
|
+
except httpx.HTTPError:
|
|
208
|
+
return _fail(
|
|
209
|
+
"Could not verify API key because Apprentice API is unavailable. "
|
|
210
|
+
"API key was not saved.",
|
|
211
|
+
bool(args.json),
|
|
212
|
+
)
|
|
213
|
+
if response.status_code in {401, 403}:
|
|
214
|
+
return _fail("API key was rejected and was not saved.", bool(args.json))
|
|
215
|
+
if not response.is_success:
|
|
216
|
+
return _fail(
|
|
217
|
+
"Could not verify API key because Apprentice API is unavailable. "
|
|
218
|
+
"API key was not saved.",
|
|
219
|
+
bool(args.json),
|
|
220
|
+
)
|
|
221
|
+
# The key is valid past this point, so a filesystem failure is the only thing
|
|
222
|
+
# left that can go wrong. Report it the same way as every other failure rather
|
|
223
|
+
# than as a traceback that emits no JSON in --json mode.
|
|
224
|
+
try:
|
|
225
|
+
return _save_api_key(key, bool(args.json))
|
|
226
|
+
except OSError as exc:
|
|
227
|
+
return _fail(f"Could not save the API key to {CONFIG_PATH}: {exc}", bool(args.json))
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _save_api_key(key: str, as_json: bool) -> int:
|
|
231
|
+
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
232
|
+
# mkdir's mode is masked by the process umask, so set it explicitly. A
|
|
233
|
+
# world-writable directory lets another local account replace the key file
|
|
234
|
+
# whatever the file's own mode is, because directory write governs entries.
|
|
235
|
+
CONFIG_PATH.parent.chmod(stat.S_IRWXU)
|
|
236
|
+
# Write private-then-rename rather than write-then-chmod. Writing first would
|
|
237
|
+
# leave the key world-readable (0644 under a normal umask, 0666 under a
|
|
238
|
+
# permissive one) until the chmod lands, and a process killed in that window
|
|
239
|
+
# leaves it that way for good. mkstemp creates at 0600, and os.replace is
|
|
240
|
+
# atomic, so a concurrent reader sees either the old file or the new one and
|
|
241
|
+
# never a half-written one. The rename also lands on the path itself, so a
|
|
242
|
+
# config.json symlink is replaced instead of its target being overwritten.
|
|
243
|
+
handle_fd, temporary_path = tempfile.mkstemp(dir=CONFIG_PATH.parent, prefix=".config-")
|
|
244
|
+
try:
|
|
245
|
+
with os.fdopen(handle_fd, "w", encoding="utf-8") as handle:
|
|
246
|
+
handle.write(json.dumps({"api_key": key}) + "\n")
|
|
247
|
+
os.replace(temporary_path, CONFIG_PATH)
|
|
248
|
+
except BaseException:
|
|
249
|
+
os.unlink(temporary_path)
|
|
250
|
+
raise
|
|
251
|
+
return _emit(
|
|
252
|
+
{"config": str(CONFIG_PATH), "mode": "600", "validated": True},
|
|
253
|
+
f"API key verified and saved to {CONFIG_PATH}.",
|
|
254
|
+
as_json,
|
|
255
|
+
)
|
|
193
256
|
|
|
194
257
|
|
|
195
258
|
def _status(args: argparse.Namespace, client: Apprentice) -> int:
|
|
@@ -474,9 +537,34 @@ def _api_key() -> str | None:
|
|
|
474
537
|
return os.environ["APPRENTICE_API_KEY"]
|
|
475
538
|
if not CONFIG_PATH.exists():
|
|
476
539
|
return None
|
|
477
|
-
|
|
540
|
+
try:
|
|
541
|
+
data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
|
542
|
+
except json.JSONDecodeError as exc:
|
|
543
|
+
raise ApprenticeError(
|
|
544
|
+
f"Could not read {CONFIG_PATH}: invalid JSON. "
|
|
545
|
+
"Run `apprentice login` to replace it."
|
|
546
|
+
) from exc
|
|
547
|
+
except (OSError, UnicodeError) as exc:
|
|
548
|
+
raise ApprenticeError(
|
|
549
|
+
f"Could not read {CONFIG_PATH}: {exc}. "
|
|
550
|
+
"Fix its permissions or set APPRENTICE_API_KEY."
|
|
551
|
+
) from exc
|
|
552
|
+
if not isinstance(data, dict):
|
|
553
|
+
raise ApprenticeError(
|
|
554
|
+
f"Could not read {CONFIG_PATH}: expected a JSON object. "
|
|
555
|
+
"Run `apprentice login` to replace it."
|
|
556
|
+
)
|
|
478
557
|
key = data.get("api_key")
|
|
479
|
-
|
|
558
|
+
if key is None:
|
|
559
|
+
return None
|
|
560
|
+
# Present but not a string: the file is malformed, which is not the same as
|
|
561
|
+
# having no key. Saying so beats silently falling through to an auth error.
|
|
562
|
+
if not isinstance(key, str):
|
|
563
|
+
raise ApprenticeError(
|
|
564
|
+
f"Could not read {CONFIG_PATH}: api_key must be a string. "
|
|
565
|
+
"Run `apprentice login` to replace it."
|
|
566
|
+
)
|
|
567
|
+
return key
|
|
480
568
|
|
|
481
569
|
|
|
482
570
|
def _job_summary(job: JobSummary) -> dict[str, Any]:
|
|
@@ -16,6 +16,7 @@ import os
|
|
|
16
16
|
import time
|
|
17
17
|
import uuid
|
|
18
18
|
from pathlib import Path
|
|
19
|
+
from urllib.parse import quote
|
|
19
20
|
from typing import Any, Literal
|
|
20
21
|
|
|
21
22
|
import httpx
|
|
@@ -34,6 +35,7 @@ from runapprentice.models import (
|
|
|
34
35
|
)
|
|
35
36
|
|
|
36
37
|
logger = logging.getLogger("runapprentice")
|
|
38
|
+
logger.addHandler(logging.NullHandler())
|
|
37
39
|
|
|
38
40
|
_DEFAULT_BASE_URL = "https://api.runapprentice.com"
|
|
39
41
|
_DEFAULT_TIMEOUT = 10.0
|
|
@@ -63,6 +65,45 @@ class ApprenticeError(Exception):
|
|
|
63
65
|
"""Raised for control-plane API failures."""
|
|
64
66
|
|
|
65
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
|
+
|
|
66
107
|
class Apprentice:
|
|
67
108
|
def __init__(
|
|
68
109
|
self,
|
|
@@ -77,6 +118,8 @@ class Apprentice:
|
|
|
77
118
|
base_url or os.environ.get("APPRENTICE_BASE_URL") or _DEFAULT_BASE_URL
|
|
78
119
|
).rstrip("/")
|
|
79
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)
|
|
80
123
|
self._api_key_present = api_key is not None
|
|
81
124
|
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
|
82
125
|
self._http = httpx.Client(base_url=self.base_url, headers=headers, timeout=timeout)
|
|
@@ -96,24 +139,48 @@ class Apprentice:
|
|
|
96
139
|
|
|
97
140
|
def _request(self, method: str, path: str, json: Any | None = None) -> Any:
|
|
98
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)"
|
|
99
147
|
try:
|
|
100
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
|
|
101
151
|
except httpx.HTTPError as exc:
|
|
102
|
-
logger.error("%s %s failed after %.0fms: %s", method,
|
|
152
|
+
logger.error("%s %s failed after %.0fms: %s", method, shown_path, _ms(t0), exc)
|
|
103
153
|
raise ApprenticeError(
|
|
104
|
-
f"{method} {
|
|
154
|
+
f"{method} {shown_path} failed: {exc}. "
|
|
105
155
|
f"Is the Apprentice backend reachable at {self.base_url}?"
|
|
106
156
|
) from exc
|
|
107
|
-
logger.debug("%s %s -> %s (%.0fms)", method,
|
|
157
|
+
logger.debug("%s %s -> %s (%.0fms)", method, shown_path, resp.status_code, _ms(t0))
|
|
108
158
|
if resp.status_code >= 400:
|
|
109
|
-
logger.error("%s %s -> %s: %s", method,
|
|
159
|
+
logger.error("%s %s -> %s: %s", method, shown_path, resp.status_code, resp.text[:500])
|
|
160
|
+
# The API sends a written reason in `detail`; lead with it so the first
|
|
161
|
+
# thing a reader sees is why the call failed. The route cannot tell us
|
|
162
|
+
# that: a 404 on /v1/prompts/{task} means the task has no optimized
|
|
163
|
+
# prompt yet, not that the task is missing.
|
|
110
164
|
suffix = (
|
|
111
|
-
"
|
|
165
|
+
" Get a free Apprentice API key at https://runapprentice.com"
|
|
112
166
|
if resp.status_code in {401, 403}
|
|
113
167
|
else ""
|
|
114
168
|
)
|
|
115
|
-
raise ApprenticeError(
|
|
116
|
-
|
|
169
|
+
raise ApprenticeError(
|
|
170
|
+
f"{_reason(resp)} ({method} {shown_path} -> {resp.status_code}){suffix}"
|
|
171
|
+
)
|
|
172
|
+
try:
|
|
173
|
+
return resp.json()
|
|
174
|
+
except ValueError as exc:
|
|
175
|
+
# A success with no body (204) or a non-JSON body from a proxy would
|
|
176
|
+
# otherwise raise JSONDecodeError, escaping every caller that catches
|
|
177
|
+
# ApprenticeError.
|
|
178
|
+
if not resp.content:
|
|
179
|
+
return {}
|
|
180
|
+
raise ApprenticeError(
|
|
181
|
+
f"{method} {shown_path} -> {resp.status_code} returned a body that is not JSON: "
|
|
182
|
+
f"{resp.text[:200]}"
|
|
183
|
+
) from exc
|
|
117
184
|
|
|
118
185
|
def optimize(
|
|
119
186
|
self,
|
|
@@ -122,6 +189,7 @@ class Apprentice:
|
|
|
122
189
|
budget: Literal["quick", "thorough"] | None = None,
|
|
123
190
|
metric: str | None = None,
|
|
124
191
|
) -> Job:
|
|
192
|
+
"""Queue hosted optimization over gold and silver rows, returning its Job immediately."""
|
|
125
193
|
payload = {"task": task}
|
|
126
194
|
payload.update(
|
|
127
195
|
{
|
|
@@ -144,6 +212,7 @@ class Apprentice:
|
|
|
144
212
|
budget: Literal["quick", "thorough"] | None = None,
|
|
145
213
|
metric: str | None = None,
|
|
146
214
|
) -> OptimizationPreview:
|
|
215
|
+
"""Return metric guidance and cost estimates without queueing optimization."""
|
|
147
216
|
payload = {"task": task}
|
|
148
217
|
payload.update(
|
|
149
218
|
{
|
|
@@ -160,22 +229,27 @@ class Apprentice:
|
|
|
160
229
|
return OptimizationPreview.model_validate(data)
|
|
161
230
|
|
|
162
231
|
def job(self, job_id: str) -> Job:
|
|
232
|
+
"""Fetch current job information and return its Job proxy."""
|
|
163
233
|
data = self._request("GET", f"/v1/jobs/{job_id}")
|
|
164
234
|
return Job(self, JobInfo.model_validate(data))
|
|
165
235
|
|
|
166
236
|
def jobs(self, task: str) -> list[JobSummary]:
|
|
167
|
-
|
|
237
|
+
"""Return every hosted job for a task, newest first, whatever its kind."""
|
|
238
|
+
data = self._request("GET", f"/v1/tasks/{quote(task, safe='')}/jobs")
|
|
168
239
|
return [JobSummary.model_validate(item) for item in data]
|
|
169
240
|
|
|
170
241
|
def cancel_job(self, job_id: str) -> dict[str, Any]:
|
|
242
|
+
"""Request job cancellation and return the API response."""
|
|
171
243
|
return dict(self._request("POST", f"/v1/jobs/{job_id}/cancel"))
|
|
172
244
|
|
|
173
245
|
def local_runs(self, task: str) -> list[LocalRun]:
|
|
174
|
-
|
|
246
|
+
"""Return tracked local training runs for a task."""
|
|
247
|
+
data = self._request("GET", f"/v1/tasks/{quote(task, safe='')}/local-runs")
|
|
175
248
|
return [LocalRun.model_validate(item) for item in data]
|
|
176
249
|
|
|
177
250
|
def train(self, task: str) -> Job:
|
|
178
|
-
|
|
251
|
+
"""Queue hosted training over gold rows only, returning its Job immediately."""
|
|
252
|
+
data = self._request("POST", f"/v1/tasks/{quote(task, safe='')}/train", json={})
|
|
179
253
|
return Job(self, JobInfo.model_validate(data))
|
|
180
254
|
|
|
181
255
|
def optimize_local(
|
|
@@ -184,6 +258,7 @@ class Apprentice:
|
|
|
184
258
|
data: str | Path | None = None,
|
|
185
259
|
**options: Any,
|
|
186
260
|
) -> dict[str, Any]:
|
|
261
|
+
"""Optimize locally using a CSV or the task's gold and silver rows, returning results."""
|
|
187
262
|
from runapprentice.local import load_csv_rows, optimize_prompt_local
|
|
188
263
|
|
|
189
264
|
if data is None:
|
|
@@ -202,6 +277,7 @@ class Apprentice:
|
|
|
202
277
|
effort: str | None = None,
|
|
203
278
|
**options: Any,
|
|
204
279
|
) -> dict[str, Any]:
|
|
280
|
+
"""Train locally using a CSV or the task's gold rows, returning run results."""
|
|
205
281
|
from runapprentice.local import load_csv_rows, train_mlx_local
|
|
206
282
|
|
|
207
283
|
if data is None:
|
|
@@ -221,6 +297,7 @@ class Apprentice:
|
|
|
221
297
|
seed: int,
|
|
222
298
|
config: dict[str, Any] | None = None,
|
|
223
299
|
) -> LocalRun:
|
|
300
|
+
"""Register a local training run and return its tracking record."""
|
|
224
301
|
data = self._request(
|
|
225
302
|
"POST",
|
|
226
303
|
"/v1/local-runs",
|
|
@@ -235,6 +312,7 @@ class Apprentice:
|
|
|
235
312
|
return LocalRun.model_validate(data)
|
|
236
313
|
|
|
237
314
|
def complete_local_run(self, run_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
315
|
+
"""Submit a local run result and return the API response."""
|
|
238
316
|
return dict(self._request("POST", f"/v1/local-runs/{run_id}/complete", json=payload))
|
|
239
317
|
|
|
240
318
|
def feedback(
|
|
@@ -244,6 +322,7 @@ class Apprentice:
|
|
|
244
322
|
score: float | None = None,
|
|
245
323
|
note: str | None = None,
|
|
246
324
|
) -> None:
|
|
325
|
+
"""Flush pending captures, then submit feedback for a trace."""
|
|
247
326
|
if self._dispatcher is not None and not self._dispatcher.flush():
|
|
248
327
|
raise ApprenticeError(
|
|
249
328
|
"feedback not sent because pending captures did not finish before the flush timeout"
|
|
@@ -367,6 +446,7 @@ class Apprentice:
|
|
|
367
446
|
logger.debug("runapprentice: %s", message)
|
|
368
447
|
|
|
369
448
|
def close(self) -> None:
|
|
449
|
+
"""Flush pending captures and close transport resources."""
|
|
370
450
|
if self._dispatcher is not None:
|
|
371
451
|
self._dispatcher.flush()
|
|
372
452
|
self._dispatcher.close()
|
|
@@ -386,11 +466,18 @@ class Job:
|
|
|
386
466
|
def status(self) -> str:
|
|
387
467
|
return self.info.status
|
|
388
468
|
|
|
469
|
+
@property
|
|
470
|
+
def error(self) -> str | None:
|
|
471
|
+
"""Return failure detail reported for the job, if any."""
|
|
472
|
+
return self.info.error
|
|
473
|
+
|
|
389
474
|
def refresh(self) -> Job:
|
|
475
|
+
"""Refresh this proxy in place and return it."""
|
|
390
476
|
self.info = self._client.job(self.info.job_id).info
|
|
391
477
|
return self
|
|
392
478
|
|
|
393
479
|
def wait(self, poll_seconds: float = 5.0, timeout_seconds: float = 3600.0) -> Job:
|
|
480
|
+
"""Poll until the job finishes; raise ApprenticeError on timeout and return this Job."""
|
|
394
481
|
deadline = time.monotonic() + timeout_seconds
|
|
395
482
|
while self.refresh().info.status in ("queued", "running"):
|
|
396
483
|
if time.monotonic() > deadline:
|
|
@@ -399,9 +486,11 @@ class Job:
|
|
|
399
486
|
return self
|
|
400
487
|
|
|
401
488
|
def cancel(self) -> dict[str, Any]:
|
|
489
|
+
"""Request cancellation and return the API response."""
|
|
402
490
|
return self._client.cancel_job(self.info.job_id)
|
|
403
491
|
|
|
404
492
|
def report(self) -> JobReport:
|
|
493
|
+
"""Return the finished job report, or raise ApprenticeError when unavailable."""
|
|
405
494
|
if self.info.report is None:
|
|
406
495
|
raise ApprenticeError(
|
|
407
496
|
f"job {self.info.job_id} has no report (status={self.info.status})"
|
|
@@ -441,13 +530,13 @@ class _DatasetsAPI:
|
|
|
441
530
|
payload_rows = [_dataset_row_payload(r, input_col, output_col) for r in rows]
|
|
442
531
|
data = self._client._request(
|
|
443
532
|
"POST",
|
|
444
|
-
f"/v1/datasets/{task}/rows",
|
|
533
|
+
f"/v1/datasets/{quote(task, safe='')}/rows",
|
|
445
534
|
json={"rows": payload_rows, "prompt": prompt},
|
|
446
535
|
)
|
|
447
536
|
return DatasetStatus.model_validate(data)
|
|
448
537
|
|
|
449
538
|
def status(self, task: str) -> DatasetStatus:
|
|
450
|
-
data = self._client._request("GET", f"/v1/datasets/{task}/status")
|
|
539
|
+
data = self._client._request("GET", f"/v1/datasets/{quote(task, safe='')}/status")
|
|
451
540
|
return DatasetStatus.model_validate(data)
|
|
452
541
|
|
|
453
542
|
def rows(self, task: str, tier: str, limit: int = 200) -> list[DatasetRow]:
|
|
@@ -456,7 +545,7 @@ class _DatasetsAPI:
|
|
|
456
545
|
while True:
|
|
457
546
|
data = self._client._request(
|
|
458
547
|
"GET",
|
|
459
|
-
f"/v1/datasets/{task}/rows?tier={tier}&limit={limit}&offset={offset}",
|
|
548
|
+
f"/v1/datasets/{quote(task, safe='')}/rows?tier={tier}&limit={limit}&offset={offset}",
|
|
460
549
|
)
|
|
461
550
|
batch = [DatasetRow.model_validate(item) for item in data.get("rows", [])]
|
|
462
551
|
rows.extend(batch)
|
|
@@ -481,21 +570,21 @@ class _PromptsAPI:
|
|
|
481
570
|
|
|
482
571
|
def register(self, task: str, template: Any) -> dict[str, Any]:
|
|
483
572
|
artifact = template if isinstance(template, dict) else _serialize_langchain_prompt(template)
|
|
484
|
-
return dict(self._client._request("POST", f"/v1/templates/{task}", json=artifact))
|
|
573
|
+
return dict(self._client._request("POST", f"/v1/templates/{quote(task, safe='')}", json=artifact))
|
|
485
574
|
|
|
486
575
|
def to_langchain(self, task: str | None = None, artifact: dict[str, Any] | None = None) -> Any:
|
|
487
576
|
if artifact is None:
|
|
488
577
|
if task is None:
|
|
489
578
|
raise ApprenticeError("provide task= or artifact=")
|
|
490
|
-
artifact = dict(self._client._request("GET", f"/v1/templates/{task}"))
|
|
579
|
+
artifact = dict(self._client._request("GET", f"/v1/templates/{quote(task, safe='')}"))
|
|
491
580
|
return _artifact_to_langchain(artifact)
|
|
492
581
|
|
|
493
582
|
def get(self, task: str, version: int | None = None) -> PromptVersion:
|
|
494
|
-
path = f"/v1/prompts/{task}" + (f"?version={version}" if version is not None else "")
|
|
583
|
+
path = f"/v1/prompts/{quote(task, safe='')}" + (f"?version={version}" if version is not None else "")
|
|
495
584
|
return PromptVersion.model_validate(self._client._request("GET", path))
|
|
496
585
|
|
|
497
586
|
def history(self, task: str) -> list[PromptVersion]:
|
|
498
|
-
data = self._client._request("GET", f"/v1/prompts/{task}/history")
|
|
587
|
+
data = self._client._request("GET", f"/v1/prompts/{quote(task, safe='')}/history")
|
|
499
588
|
return [PromptVersion.model_validate(p) for p in data]
|
|
500
589
|
|
|
501
590
|
|
|
@@ -56,6 +56,9 @@ class HoldoutEvidenceRow(BaseModel):
|
|
|
56
56
|
baseline_score: float | None = None
|
|
57
57
|
optimized_output: str | None = None
|
|
58
58
|
optimized_score: float | None = None
|
|
59
|
+
# Set when the row's 0 came from an evaluation error, not a wrong answer.
|
|
60
|
+
baseline_error: str | None = None
|
|
61
|
+
optimized_error: str | None = None
|
|
59
62
|
|
|
60
63
|
|
|
61
64
|
class OptimizationReport(BaseModel):
|
|
@@ -49,8 +49,10 @@ def wrap(openai_client: Any, client: Apprentice, task: str, redact: RedactFn | N
|
|
|
49
49
|
helper and posts on its own rather than calling `create`.
|
|
50
50
|
|
|
51
51
|
Returns the same client (mutated in place) for convenient one-line use.
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
Re-wrapping the same client for the same task is a no-op; re-wrapping it for a
|
|
53
|
+
different task or a different Apprentice client rebinds it, so reusing one
|
|
54
|
+
OpenAI client across tasks sends each task's traffic to its own dataset.
|
|
55
|
+
Supports both sync (`OpenAI`) and async (`AsyncOpenAI`) clients.
|
|
54
56
|
"""
|
|
55
57
|
chat = getattr(openai_client, "chat", None)
|
|
56
58
|
resources = (
|
|
@@ -87,10 +89,13 @@ def _wrap_method(
|
|
|
87
89
|
input_tokens_field: str,
|
|
88
90
|
output_tokens_field: str,
|
|
89
91
|
) -> None:
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
if getattr(
|
|
92
|
+
current = getattr(resource, method_name)
|
|
93
|
+
binding = (client, task)
|
|
94
|
+
if getattr(current, "_apprentice_binding", None) == binding:
|
|
93
95
|
return
|
|
96
|
+
# Already wrapped for a different task or client: rebind by wrapping the real
|
|
97
|
+
# method again, never a wrapper. Otherwise the first task keeps every trace.
|
|
98
|
+
original = getattr(current, "_apprentice_original", current)
|
|
94
99
|
|
|
95
100
|
def _capture(response: Any, kwargs: dict[str, Any], args: tuple[Any, ...], t0: float) -> None:
|
|
96
101
|
try:
|
|
@@ -135,9 +140,9 @@ def _wrap_method(
|
|
|
135
140
|
_capture(response, kwargs, args, t0)
|
|
136
141
|
return response
|
|
137
142
|
|
|
138
|
-
wrapped.
|
|
143
|
+
wrapped._apprentice_original = original # type: ignore[attr-defined]
|
|
144
|
+
wrapped._apprentice_binding = binding # type: ignore[attr-defined]
|
|
139
145
|
setattr(resource, method_name, wrapped)
|
|
140
|
-
setattr(resource, flag, True)
|
|
141
146
|
|
|
142
147
|
|
|
143
148
|
def _messages_to_text(messages: Any) -> str:
|
|
@@ -1,17 +1,40 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import os
|
|
4
5
|
import stat
|
|
6
|
+
from io import StringIO
|
|
5
7
|
from pathlib import Path
|
|
6
|
-
from typing import Any
|
|
8
|
+
from typing import Any, Callable
|
|
7
9
|
|
|
10
|
+
import httpx
|
|
8
11
|
import pytest
|
|
9
12
|
|
|
10
|
-
from runapprentice import ApprenticeError, cli
|
|
13
|
+
from runapprentice import Apprentice, ApprenticeError, cli
|
|
11
14
|
import runapprentice.local as local
|
|
12
15
|
from runapprentice.models import DatasetRow, JobInfo, JobSummary
|
|
13
16
|
|
|
14
17
|
|
|
18
|
+
def _mock_login_api(
|
|
19
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
20
|
+
handler: Callable[[httpx.Request], httpx.Response],
|
|
21
|
+
) -> None:
|
|
22
|
+
def client(*, api_key: str, **_kwargs: Any) -> Apprentice:
|
|
23
|
+
result = Apprentice(
|
|
24
|
+
api_key=api_key,
|
|
25
|
+
base_url="http://mock",
|
|
26
|
+
capture_async=False,
|
|
27
|
+
)
|
|
28
|
+
result._http = httpx.Client(
|
|
29
|
+
base_url="http://mock",
|
|
30
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
31
|
+
transport=httpx.MockTransport(handler),
|
|
32
|
+
)
|
|
33
|
+
return result
|
|
34
|
+
|
|
35
|
+
monkeypatch.setattr(cli, "Apprentice", client)
|
|
36
|
+
|
|
37
|
+
|
|
15
38
|
def test_parser_supports_required_commands() -> None:
|
|
16
39
|
parser = cli.build_parser()
|
|
17
40
|
|
|
@@ -48,6 +71,33 @@ def test_parser_supports_required_commands() -> None:
|
|
|
48
71
|
assert parser.parse_args(["cancel", "j1"]).command == "cancel"
|
|
49
72
|
|
|
50
73
|
|
|
74
|
+
def test_task_not_found_prints_once(
|
|
75
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
76
|
+
) -> None:
|
|
77
|
+
client = Apprentice(api_key="test", base_url="http://mock", capture_async=False)
|
|
78
|
+
client._http = httpx.Client(
|
|
79
|
+
base_url="http://mock",
|
|
80
|
+
transport=httpx.MockTransport(
|
|
81
|
+
lambda _request: httpx.Response(404, json={"detail": "task not found"})
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
monkeypatch.setattr(cli, "Apprentice", lambda **_kwargs: client)
|
|
85
|
+
monkeypatch.setattr(cli, "CONFIG_PATH", tmp_path / "config.json")
|
|
86
|
+
monkeypatch.setattr(cli, "PENDING_DIR", tmp_path / "pending")
|
|
87
|
+
|
|
88
|
+
assert cli.main(["status", "no-such-task"]) == 1
|
|
89
|
+
|
|
90
|
+
assert capsys.readouterr().err.splitlines() == [
|
|
91
|
+
"task not found (GET /v1/datasets/no-such-task/status -> 404)"
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
assert cli.main(["--json", "status", "no-such-task"]) == 1
|
|
95
|
+
payload = json.loads(capsys.readouterr().err)
|
|
96
|
+
# --json keeps both halves: the reason to show a user, and the request that
|
|
97
|
+
# produced it so the failure can still be traced.
|
|
98
|
+
assert payload["error"] == "task not found (GET /v1/datasets/no-such-task/status -> 404)"
|
|
99
|
+
|
|
100
|
+
|
|
51
101
|
def test_cli_renders_all_job_kinds() -> None:
|
|
52
102
|
for kind, report in (
|
|
53
103
|
("optimize", {"task": "extract", "baseline_score": 50.0}),
|
|
@@ -283,13 +333,110 @@ def test_login_writes_config_0600(
|
|
|
283
333
|
config_path = config_dir / "config.json"
|
|
284
334
|
monkeypatch.setattr(cli, "CONFIG_DIR", config_dir)
|
|
285
335
|
monkeypatch.setattr(cli, "CONFIG_PATH", config_path)
|
|
336
|
+
monkeypatch.setattr(cli.sys.stdin, "isatty", lambda: True)
|
|
286
337
|
monkeypatch.setattr("runapprentice.cli.getpass.getpass", lambda _: "secret-key")
|
|
338
|
+
_mock_login_api(monkeypatch, lambda _request: httpx.Response(200, json=[]))
|
|
287
339
|
|
|
288
340
|
assert cli.main(["login"]) == 0
|
|
289
341
|
|
|
290
342
|
assert json.loads(config_path.read_text(encoding="utf-8")) == {"api_key": "secret-key"}
|
|
291
343
|
assert stat.S_IMODE(config_path.stat().st_mode) == 0o600
|
|
292
|
-
|
|
344
|
+
out = capsys.readouterr().out
|
|
345
|
+
assert "secret-key" not in out
|
|
346
|
+
assert "API key verified" in out
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def test_login_rejects_invalid_key_without_writing(
|
|
350
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
351
|
+
) -> None:
|
|
352
|
+
config_path = tmp_path / "config.json"
|
|
353
|
+
monkeypatch.setattr(cli, "CONFIG_PATH", config_path)
|
|
354
|
+
monkeypatch.setattr(cli.sys, "stdin", StringIO("invalid-key\n"))
|
|
355
|
+
_mock_login_api(
|
|
356
|
+
monkeypatch,
|
|
357
|
+
lambda _request: httpx.Response(401, json={"detail": "invalid API key"}),
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
assert cli.main(["login"]) == 1
|
|
361
|
+
assert not config_path.exists()
|
|
362
|
+
assert capsys.readouterr().err == "API key was rejected and was not saved.\n"
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def test_failed_login_does_not_clobber_existing_config(
|
|
366
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
367
|
+
) -> None:
|
|
368
|
+
config_path = tmp_path / "config.json"
|
|
369
|
+
original = '{"api_key": "working-key"}\n'
|
|
370
|
+
config_path.write_text(original, encoding="utf-8")
|
|
371
|
+
monkeypatch.setattr(cli, "CONFIG_PATH", config_path)
|
|
372
|
+
monkeypatch.setattr(cli.sys, "stdin", StringIO("invalid-key\n"))
|
|
373
|
+
_mock_login_api(monkeypatch, lambda _request: httpx.Response(403, json={"detail": "forbidden"}))
|
|
374
|
+
|
|
375
|
+
assert cli.main(["login"]) == 1
|
|
376
|
+
assert config_path.read_text(encoding="utf-8") == original
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def test_login_reports_unreachable_api_differently_from_rejected_key(
|
|
380
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
381
|
+
) -> None:
|
|
382
|
+
monkeypatch.setattr(cli, "CONFIG_PATH", tmp_path / "config.json")
|
|
383
|
+
monkeypatch.setattr(cli.sys, "stdin", StringIO("candidate-key\n"))
|
|
384
|
+
|
|
385
|
+
def unreachable(request: httpx.Request) -> httpx.Response:
|
|
386
|
+
raise httpx.ConnectError("connection refused", request=request)
|
|
387
|
+
|
|
388
|
+
_mock_login_api(monkeypatch, unreachable)
|
|
389
|
+
|
|
390
|
+
assert cli.main(["login"]) == 1
|
|
391
|
+
err = capsys.readouterr().err
|
|
392
|
+
assert "API is unavailable" in err
|
|
393
|
+
assert "rejected" not in err
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def test_login_reports_server_error_as_unavailable(
|
|
397
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
398
|
+
) -> None:
|
|
399
|
+
config_path = tmp_path / "config.json"
|
|
400
|
+
monkeypatch.setattr(cli, "CONFIG_PATH", config_path)
|
|
401
|
+
monkeypatch.setattr(cli.sys, "stdin", StringIO("candidate-key\n"))
|
|
402
|
+
_mock_login_api(monkeypatch, lambda _request: httpx.Response(503))
|
|
403
|
+
|
|
404
|
+
assert cli.main(["login"]) == 1
|
|
405
|
+
assert not config_path.exists()
|
|
406
|
+
assert "API is unavailable" in capsys.readouterr().err
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def test_corrupt_config_reports_clean_error(
|
|
410
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
411
|
+
) -> None:
|
|
412
|
+
config_path = tmp_path / "config.json"
|
|
413
|
+
config_path.write_text("not json{", encoding="utf-8")
|
|
414
|
+
monkeypatch.setattr(cli, "CONFIG_PATH", config_path)
|
|
415
|
+
|
|
416
|
+
assert cli.main(["status", "extract"]) == 1
|
|
417
|
+
err = capsys.readouterr().err
|
|
418
|
+
assert "invalid JSON" in err
|
|
419
|
+
assert "apprentice login" in err
|
|
420
|
+
assert "Traceback" not in err
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def test_login_reads_piped_stdin_and_validates_it(
|
|
424
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
425
|
+
) -> None:
|
|
426
|
+
config_path = tmp_path / "config.json"
|
|
427
|
+
monkeypatch.setattr(cli, "CONFIG_PATH", config_path)
|
|
428
|
+
monkeypatch.setattr(cli.sys, "stdin", StringIO(" piped-key \n"))
|
|
429
|
+
|
|
430
|
+
def validate(request: httpx.Request) -> httpx.Response:
|
|
431
|
+
assert request.url.path == "/v1/tasks"
|
|
432
|
+
assert request.headers["Authorization"] == "Bearer piped-key"
|
|
433
|
+
return httpx.Response(200, json=[{"name": "extract"}])
|
|
434
|
+
|
|
435
|
+
_mock_login_api(monkeypatch, validate)
|
|
436
|
+
|
|
437
|
+
assert cli.main(["--json", "login"]) == 0
|
|
438
|
+
assert json.loads(config_path.read_text(encoding="utf-8")) == {"api_key": "piped-key"}
|
|
439
|
+
assert json.loads(capsys.readouterr().out)["validated"] is True
|
|
293
440
|
|
|
294
441
|
|
|
295
442
|
def test_env_api_key_overrides_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
@@ -388,3 +535,158 @@ def test_local_optimize_missing_dspy_message(
|
|
|
388
535
|
|
|
389
536
|
assert cli.main(["optimize", "extract", "--local", "--data", str(data)]) == 1
|
|
390
537
|
assert "pip install 'runapprentice[optimize]'" in capsys.readouterr().err
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def test_login_rejects_a_key_containing_whitespace(
|
|
541
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
542
|
+
) -> None:
|
|
543
|
+
"""A two-line file piped in produces a key with a newline in it.
|
|
544
|
+
|
|
545
|
+
httpx raises LocalProtocolError on the header before sending, so without
|
|
546
|
+
this guard the user is told the API is unavailable when nothing was sent.
|
|
547
|
+
"""
|
|
548
|
+
config_path = tmp_path / "config.json"
|
|
549
|
+
monkeypatch.setattr(cli, "CONFIG_PATH", config_path)
|
|
550
|
+
monkeypatch.setattr(cli.sys, "stdin", StringIO("good-key\nJUNK-SECOND-LINE\n"))
|
|
551
|
+
|
|
552
|
+
def explode(_request: httpx.Request) -> httpx.Response:
|
|
553
|
+
raise AssertionError("a malformed key must never reach the network")
|
|
554
|
+
|
|
555
|
+
_mock_login_api(monkeypatch, explode)
|
|
556
|
+
|
|
557
|
+
assert cli.main(["login"]) == 1
|
|
558
|
+
assert not config_path.exists()
|
|
559
|
+
assert capsys.readouterr().err == "API key contains whitespace, so it was not saved.\n"
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def test_login_directory_is_not_world_writable_under_a_permissive_umask(
|
|
563
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
564
|
+
) -> None:
|
|
565
|
+
"""mkdir's mode is masked by the umask, so it must be set explicitly.
|
|
566
|
+
|
|
567
|
+
A world-writable parent lets another local account replace the key file no
|
|
568
|
+
matter what the file's own mode is.
|
|
569
|
+
"""
|
|
570
|
+
config_path = tmp_path / "nested" / "config.json"
|
|
571
|
+
monkeypatch.setattr(cli, "CONFIG_PATH", config_path)
|
|
572
|
+
monkeypatch.setattr(cli.sys, "stdin", StringIO("good-key\n"))
|
|
573
|
+
_mock_login_api(monkeypatch, lambda _request: httpx.Response(200, json=[]))
|
|
574
|
+
|
|
575
|
+
previous = os.umask(0)
|
|
576
|
+
try:
|
|
577
|
+
assert cli.main(["login"]) == 0
|
|
578
|
+
finally:
|
|
579
|
+
os.umask(previous)
|
|
580
|
+
|
|
581
|
+
assert stat.S_IMODE(config_path.parent.stat().st_mode) == 0o700
|
|
582
|
+
assert stat.S_IMODE(config_path.stat().st_mode) == 0o600
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def test_config_with_a_non_string_api_key_reports_instead_of_silently_ignoring(
|
|
586
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
587
|
+
) -> None:
|
|
588
|
+
config_path = tmp_path / "config.json"
|
|
589
|
+
config_path.write_text(json.dumps({"api_key": 12345}), encoding="utf-8")
|
|
590
|
+
monkeypatch.setattr(cli, "CONFIG_PATH", config_path)
|
|
591
|
+
monkeypatch.delenv("APPRENTICE_API_KEY", raising=False)
|
|
592
|
+
|
|
593
|
+
assert cli.main(["status", "any-task"]) == 1
|
|
594
|
+
assert "api_key must be a string" in capsys.readouterr().err
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
@pytest.mark.parametrize(
|
|
598
|
+
"key",
|
|
599
|
+
["ap_live_café", "ap_live_\U0001f511", "ap_live_a\x01b"],
|
|
600
|
+
ids=["accented", "emoji", "control-char"],
|
|
601
|
+
)
|
|
602
|
+
def test_login_rejects_a_key_that_is_not_printable_ascii(
|
|
603
|
+
key: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
604
|
+
) -> None:
|
|
605
|
+
"""httpx encodes headers as ASCII and raises UnicodeEncodeError otherwise.
|
|
606
|
+
|
|
607
|
+
That is not an httpx.HTTPError, so without this guard it escapes _login's
|
|
608
|
+
handler and reaches the user as a traceback.
|
|
609
|
+
"""
|
|
610
|
+
config_path = tmp_path / "config.json"
|
|
611
|
+
monkeypatch.setattr(cli, "CONFIG_PATH", config_path)
|
|
612
|
+
monkeypatch.setattr(cli.sys, "stdin", StringIO(key + "\n"))
|
|
613
|
+
|
|
614
|
+
def explode(_request: httpx.Request) -> httpx.Response:
|
|
615
|
+
raise AssertionError("a malformed key must never reach the network")
|
|
616
|
+
|
|
617
|
+
_mock_login_api(monkeypatch, explode)
|
|
618
|
+
|
|
619
|
+
assert cli.main(["login"]) == 1
|
|
620
|
+
assert not config_path.exists()
|
|
621
|
+
assert "printable ASCII" in capsys.readouterr().err
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def test_login_never_leaves_the_key_world_readable(
|
|
625
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
626
|
+
) -> None:
|
|
627
|
+
"""The key must never exist on disk at a readable mode, even briefly.
|
|
628
|
+
|
|
629
|
+
write-then-chmod leaves it at 0666 under a permissive umask until the chmod
|
|
630
|
+
lands; a process killed in that window leaves it that way permanently. This
|
|
631
|
+
asserts the mode observed at creation, not just the final mode.
|
|
632
|
+
"""
|
|
633
|
+
config_path = tmp_path / "nested" / "config.json"
|
|
634
|
+
monkeypatch.setattr(cli, "CONFIG_PATH", config_path)
|
|
635
|
+
monkeypatch.setattr(cli.sys, "stdin", StringIO("good-key\n"))
|
|
636
|
+
_mock_login_api(monkeypatch, lambda _request: httpx.Response(200, json=[]))
|
|
637
|
+
|
|
638
|
+
seen_modes: list[int] = []
|
|
639
|
+
real_replace = os.replace
|
|
640
|
+
|
|
641
|
+
def spy(src: Any, dst: Any) -> None:
|
|
642
|
+
# The moment before the file becomes visible at its final path.
|
|
643
|
+
seen_modes.append(stat.S_IMODE(os.stat(src).st_mode))
|
|
644
|
+
real_replace(src, dst)
|
|
645
|
+
|
|
646
|
+
monkeypatch.setattr(cli.os, "replace", spy)
|
|
647
|
+
|
|
648
|
+
previous = os.umask(0)
|
|
649
|
+
try:
|
|
650
|
+
assert cli.main(["login"]) == 0
|
|
651
|
+
finally:
|
|
652
|
+
os.umask(previous)
|
|
653
|
+
|
|
654
|
+
assert seen_modes == [0o600], f"key was written at {[oct(m) for m in seen_modes]}"
|
|
655
|
+
assert stat.S_IMODE(config_path.stat().st_mode) == 0o600
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def test_login_replaces_a_symlinked_config_instead_of_its_target(
|
|
659
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
660
|
+
) -> None:
|
|
661
|
+
victim = tmp_path / "victim.txt"
|
|
662
|
+
victim.write_text("IMPORTANT DATA", encoding="utf-8")
|
|
663
|
+
config_path = tmp_path / "config.json"
|
|
664
|
+
config_path.symlink_to(victim)
|
|
665
|
+
monkeypatch.setattr(cli, "CONFIG_PATH", config_path)
|
|
666
|
+
monkeypatch.setattr(cli.sys, "stdin", StringIO("good-key\n"))
|
|
667
|
+
_mock_login_api(monkeypatch, lambda _request: httpx.Response(200, json=[]))
|
|
668
|
+
|
|
669
|
+
assert cli.main(["login"]) == 0
|
|
670
|
+
|
|
671
|
+
assert victim.read_text(encoding="utf-8") == "IMPORTANT DATA"
|
|
672
|
+
assert not config_path.is_symlink()
|
|
673
|
+
assert json.loads(config_path.read_text(encoding="utf-8"))["api_key"] == "good-key"
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def test_login_reports_a_filesystem_failure_instead_of_a_traceback(
|
|
677
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
678
|
+
) -> None:
|
|
679
|
+
"""The key validated, so only the write can still fail.
|
|
680
|
+
|
|
681
|
+
In --json mode a traceback also means no JSON is emitted at all, which breaks
|
|
682
|
+
the contract that --json always prints one object.
|
|
683
|
+
"""
|
|
684
|
+
# A path whose parent cannot exist: /dev/null is a file, not a directory.
|
|
685
|
+
monkeypatch.setattr(cli, "CONFIG_PATH", Path("/dev/null/nope/config.json"))
|
|
686
|
+
monkeypatch.setattr(cli.sys, "stdin", StringIO("good-key\n"))
|
|
687
|
+
_mock_login_api(monkeypatch, lambda _request: httpx.Response(200, json=[]))
|
|
688
|
+
|
|
689
|
+
assert cli.main(["--json", "login"]) == 1
|
|
690
|
+
|
|
691
|
+
payload = json.loads(capsys.readouterr().err)
|
|
692
|
+
assert "Could not save the API key" in payload["error"]
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Control-plane client: upload, status, optimize/job lifecycle, error clarity."""
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import logging
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
import re
|
|
6
7
|
import threading
|
|
@@ -10,7 +11,7 @@ from typing import Any
|
|
|
10
11
|
import httpx
|
|
11
12
|
import pytest
|
|
12
13
|
|
|
13
|
-
from runapprentice import Apprentice, ApprenticeError, TraceRecord
|
|
14
|
+
from runapprentice import Apprentice, ApprenticeError, Job, TraceRecord
|
|
14
15
|
from runapprentice._capture import CaptureDispatcher
|
|
15
16
|
import runapprentice.local as local
|
|
16
17
|
from runapprentice.models import (
|
|
@@ -38,6 +39,14 @@ def make_client(routes: dict[str, Any]) -> tuple[Apprentice, list[dict[str, Any]
|
|
|
38
39
|
return client, received
|
|
39
40
|
|
|
40
41
|
|
|
42
|
+
def test_package_logging_is_silent_by_default(capsys: pytest.CaptureFixture[str]) -> None:
|
|
43
|
+
logger = logging.getLogger("runapprentice")
|
|
44
|
+
|
|
45
|
+
assert any(isinstance(handler, logging.NullHandler) for handler in logger.handlers)
|
|
46
|
+
logger.error("must stay silent")
|
|
47
|
+
assert capsys.readouterr().err == ""
|
|
48
|
+
|
|
49
|
+
|
|
41
50
|
def test_dataset_upload_from_rows() -> None:
|
|
42
51
|
client, received = make_client(
|
|
43
52
|
{
|
|
@@ -234,6 +243,15 @@ def test_optimize_and_job_lifecycle() -> None:
|
|
|
234
243
|
assert report.optimized_score == 88.0
|
|
235
244
|
|
|
236
245
|
|
|
246
|
+
def test_job_error_is_reachable() -> None:
|
|
247
|
+
job = Job(
|
|
248
|
+
Apprentice(api_key="test", base_url="http://mock", capture_async=False),
|
|
249
|
+
JobInfo(job_id="j1", task="t1", kind="train", status="failed", error="training failed"),
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
assert job.error == "training failed"
|
|
253
|
+
|
|
254
|
+
|
|
237
255
|
def test_optimization_report_round_trips_every_api_field() -> None:
|
|
238
256
|
payload = {
|
|
239
257
|
"task": "extract",
|
|
@@ -283,6 +301,9 @@ def test_optimization_report_round_trips_every_api_field() -> None:
|
|
|
283
301
|
"baseline_score": 0.0,
|
|
284
302
|
"optimized_output": '{"total": 10}',
|
|
285
303
|
"optimized_score": 1.0,
|
|
304
|
+
# Distinguishes a 0 from a wrong answer and a 0 from a crashed row.
|
|
305
|
+
"baseline_error": None,
|
|
306
|
+
"optimized_error": "TimeoutError: judge call timed out",
|
|
286
307
|
}
|
|
287
308
|
],
|
|
288
309
|
"improved_count": 1,
|
|
@@ -600,6 +621,79 @@ def test_http_4xx_raises_with_body() -> None:
|
|
|
600
621
|
client.tasks.create("nope")
|
|
601
622
|
|
|
602
623
|
|
|
624
|
+
def test_error_leads_with_the_reason_the_api_gave() -> None:
|
|
625
|
+
client, _ = make_client({})
|
|
626
|
+
|
|
627
|
+
with pytest.raises(ApprenticeError) as exc_info:
|
|
628
|
+
client.datasets.status("no-such-task")
|
|
629
|
+
|
|
630
|
+
assert str(exc_info.value) == (
|
|
631
|
+
"not found (GET /v1/datasets/no-such-task/status -> 404)"
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
@pytest.mark.parametrize(
|
|
636
|
+
"body",
|
|
637
|
+
[["upstream failed"], "plain string", 42, None],
|
|
638
|
+
ids=["list", "string", "number", "null"],
|
|
639
|
+
)
|
|
640
|
+
def test_error_bodies_that_are_not_json_objects_still_raise_apprentice_error(body: object) -> None:
|
|
641
|
+
"""A gateway can return any JSON shape, not the object FastAPI sends.
|
|
642
|
+
|
|
643
|
+
Reading `.get("detail")` off a list or string raises AttributeError, which
|
|
644
|
+
escapes past every caller that catches ApprenticeError.
|
|
645
|
+
"""
|
|
646
|
+
client = Apprentice(api_key="k", base_url="http://mock", capture_async=False)
|
|
647
|
+
client._http = httpx.Client(
|
|
648
|
+
base_url="http://mock",
|
|
649
|
+
transport=httpx.MockTransport(lambda _request: httpx.Response(500, json=body)),
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
with pytest.raises(ApprenticeError):
|
|
653
|
+
client._request("GET", "/v1/jobs/x")
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def test_a_long_server_detail_is_capped() -> None:
|
|
657
|
+
client = Apprentice(api_key="k", base_url="http://mock", capture_async=False)
|
|
658
|
+
client._http = httpx.Client(
|
|
659
|
+
base_url="http://mock",
|
|
660
|
+
transport=httpx.MockTransport(
|
|
661
|
+
lambda _request: httpx.Response(500, json={"detail": "x" * 5000})
|
|
662
|
+
),
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
with pytest.raises(ApprenticeError) as exc_info:
|
|
666
|
+
client._request("GET", "/v1/jobs/x")
|
|
667
|
+
|
|
668
|
+
assert len(str(exc_info.value)) < 700
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def test_a_404_does_not_claim_the_task_is_missing() -> None:
|
|
672
|
+
"""A 404 on a task-scoped route often means something else is missing.
|
|
673
|
+
|
|
674
|
+
/v1/templates/{task} and /v1/prompts/{task} both 404 for a task that exists
|
|
675
|
+
and is healthy, so reading "task not found" off the route shape states
|
|
676
|
+
something false. Verified against production: a template fetch for a task
|
|
677
|
+
with 24 rows and a succeeded optimize job returns 404 "no template".
|
|
678
|
+
"""
|
|
679
|
+
client = Apprentice(api_key="k", base_url="http://mock", capture_async=False)
|
|
680
|
+
client._http = httpx.Client(
|
|
681
|
+
base_url="http://mock",
|
|
682
|
+
transport=httpx.MockTransport(
|
|
683
|
+
lambda _request: httpx.Response(
|
|
684
|
+
404, json={"detail": "no optimized prompt for task 'live' yet. Run optimize first."}
|
|
685
|
+
)
|
|
686
|
+
),
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
with pytest.raises(ApprenticeError) as exc_info:
|
|
690
|
+
client._request("GET", "/v1/prompts/live")
|
|
691
|
+
|
|
692
|
+
message = str(exc_info.value)
|
|
693
|
+
assert message.startswith("no optimized prompt for task 'live' yet.")
|
|
694
|
+
assert "was not found" not in message
|
|
695
|
+
|
|
696
|
+
|
|
603
697
|
def test_auth_error_mentions_free_key() -> None:
|
|
604
698
|
client = Apprentice(api_key=None, base_url="http://mock", capture_async=False)
|
|
605
699
|
client._http = httpx.Client(
|
|
@@ -774,3 +868,100 @@ def test_messages_needs_no_keywords_when_partials_fill_everything() -> None:
|
|
|
774
868
|
def test_messages_without_an_artifact_still_demands_inputs() -> None:
|
|
775
869
|
with pytest.raises(ValueError, match="keyword arguments"):
|
|
776
870
|
_prompt_version(artifact=None).messages()
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def test_task_names_with_url_delimiters_are_quoted() -> None:
|
|
874
|
+
"""The API accepts '/' and '?' in task names (main.py validates length only),
|
|
875
|
+
so an unquoted name reshapes the request path and the task becomes
|
|
876
|
+
unreachable from the SDK. httpx decodes `.path`, so assert on the raw target."""
|
|
877
|
+
seen: list[bytes] = []
|
|
878
|
+
|
|
879
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
880
|
+
seen.append(request.url.raw_path)
|
|
881
|
+
return httpx.Response(200, json={"task": "a/b?c", "gold": 0, "silver": 0, "raw": 0})
|
|
882
|
+
|
|
883
|
+
client = Apprentice(api_key="test", base_url="http://mock", capture_async=False)
|
|
884
|
+
client._http = httpx.Client(base_url="http://mock", transport=httpx.MockTransport(handler))
|
|
885
|
+
|
|
886
|
+
client.datasets.status("a/b?c")
|
|
887
|
+
|
|
888
|
+
assert seen[0] == b"/v1/datasets/a%2Fb%3Fc/status"
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def test_a_success_with_no_body_does_not_leak_a_json_error() -> None:
|
|
892
|
+
"""204 and non-JSON success bodies must not escape as JSONDecodeError.
|
|
893
|
+
|
|
894
|
+
A caller catching ApprenticeError would otherwise crash on a proxy response.
|
|
895
|
+
"""
|
|
896
|
+
client = Apprentice(api_key="k", base_url="http://mock", capture_async=False)
|
|
897
|
+
client._http = httpx.Client(
|
|
898
|
+
base_url="http://mock",
|
|
899
|
+
transport=httpx.MockTransport(lambda _request: httpx.Response(204)),
|
|
900
|
+
)
|
|
901
|
+
|
|
902
|
+
assert client._request("DELETE", "/v1/keys/x") == {}
|
|
903
|
+
|
|
904
|
+
client._http = httpx.Client(
|
|
905
|
+
base_url="http://mock",
|
|
906
|
+
transport=httpx.MockTransport(lambda _request: httpx.Response(200, text="<html>hi</html>")),
|
|
907
|
+
)
|
|
908
|
+
with pytest.raises(ApprenticeError, match="not JSON"):
|
|
909
|
+
client._request("GET", "/v1/jobs/x")
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
@pytest.mark.parametrize(
|
|
913
|
+
"key", ["ap_live_café", "ap_live_a b", "ap_live_\x01"], ids=["non-ascii", "space", "control"]
|
|
914
|
+
)
|
|
915
|
+
def test_client_rejects_a_key_that_cannot_become_a_header(key: str) -> None:
|
|
916
|
+
"""Every path that supplies a key must fail the same way.
|
|
917
|
+
|
|
918
|
+
httpx raises UnicodeEncodeError deep in the transport otherwise, which is not
|
|
919
|
+
an ApprenticeError and reaches a CLI user as a traceback.
|
|
920
|
+
"""
|
|
921
|
+
with pytest.raises(ApprenticeError):
|
|
922
|
+
Apprentice(api_key=key, base_url="http://mock", capture_async=False)
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
def test_api_key_from_the_environment_is_validated_too(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
926
|
+
monkeypatch.setenv("APPRENTICE_API_KEY", "ap_live_café")
|
|
927
|
+
with pytest.raises(ApprenticeError):
|
|
928
|
+
Apprentice(base_url="http://mock", capture_async=False)
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
def test_an_absurd_path_is_capped_and_never_leaks_invalid_url() -> None:
|
|
932
|
+
"""A caller-supplied name lands in the path, so it must not size the message.
|
|
933
|
+
|
|
934
|
+
The 500-char body cap is defeated if the path itself is unbounded, and
|
|
935
|
+
httpx.InvalidURL is not an HTTPError so it would escape the handler.
|
|
936
|
+
"""
|
|
937
|
+
client = Apprentice(api_key="safe", base_url="http://mock", capture_async=False)
|
|
938
|
+
client._http = httpx.Client(
|
|
939
|
+
base_url="http://mock",
|
|
940
|
+
transport=httpx.MockTransport(lambda _request: httpx.Response(400, json={"detail": "bad"})),
|
|
941
|
+
)
|
|
942
|
+
|
|
943
|
+
for size in (60_000, 66_000):
|
|
944
|
+
with pytest.raises(ApprenticeError) as exc_info:
|
|
945
|
+
client.datasets.status("x" * size)
|
|
946
|
+
assert len(str(exc_info.value)) < 400, f"message was {len(str(exc_info.value))} chars"
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def test_a_hostile_server_message_cannot_repaint_the_terminal() -> None:
|
|
950
|
+
"""Error text is server-controlled and lands in terminals and CI logs.
|
|
951
|
+
|
|
952
|
+
Escape sequences there can clear the screen and rewrite earlier lines, so a
|
|
953
|
+
backend could make its own error read as a success.
|
|
954
|
+
"""
|
|
955
|
+
hostile = "ok\x1b[2J\x1b[Hnothing to see\x07\rFAKE: login succeeded"
|
|
956
|
+
client = Apprentice(api_key="k", base_url="http://mock", capture_async=False)
|
|
957
|
+
client._http = httpx.Client(
|
|
958
|
+
base_url="http://mock",
|
|
959
|
+
transport=httpx.MockTransport(lambda _request: httpx.Response(400, json={"detail": hostile})),
|
|
960
|
+
)
|
|
961
|
+
|
|
962
|
+
with pytest.raises(ApprenticeError) as exc_info:
|
|
963
|
+
client._request("GET", "/v1/jobs/x")
|
|
964
|
+
|
|
965
|
+
message = str(exc_info.value)
|
|
966
|
+
assert "\x1b" not in message and "\x07" not in message and "\r" not in message
|
|
967
|
+
assert "FAKE: login succeeded" in message # kept, but inert
|
|
@@ -106,11 +106,15 @@ def test_wrap_captures_and_returns_response() -> None:
|
|
|
106
106
|
|
|
107
107
|
|
|
108
108
|
def test_wrap_is_idempotent() -> None:
|
|
109
|
+
"""Same Apprentice client and same task: the wrapper is left alone. A
|
|
110
|
+
different client or task rebinds instead, so traces follow what was asked
|
|
111
|
+
for (see test_rewrapping_for_a_new_task_reroutes_traces)."""
|
|
109
112
|
oa = _FakeOpenAI()
|
|
110
|
-
|
|
113
|
+
client = _client()
|
|
114
|
+
wrap(oa, client, task="t")
|
|
111
115
|
chat_once = oa.chat.completions.create
|
|
112
116
|
responses_once = oa.responses.create
|
|
113
|
-
wrap(oa,
|
|
117
|
+
wrap(oa, client, task="t")
|
|
114
118
|
assert oa.chat.completions.create is chat_once
|
|
115
119
|
assert oa.responses.create is responses_once
|
|
116
120
|
|
|
@@ -252,3 +256,33 @@ def test_resource_without_parse_still_wraps(monkeypatch: Any) -> None:
|
|
|
252
256
|
oa.responses.create(model="gpt-5-mini", input="x")
|
|
253
257
|
|
|
254
258
|
assert len(captured) == 1
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def test_rewrapping_for_a_new_task_reroutes_traces(monkeypatch: Any) -> None:
|
|
262
|
+
"""Reusing one OpenAI client for a second task must not keep filing traces
|
|
263
|
+
under the first. Shipped wrong in 0.4.0: the flag made re-wrap a no-op, so
|
|
264
|
+
task B's traffic silently landed in task A's dataset."""
|
|
265
|
+
client = _client()
|
|
266
|
+
captured: list[Any] = []
|
|
267
|
+
monkeypatch.setattr(client, "post_trace_failopen", lambda record: captured.append(record))
|
|
268
|
+
oa = _FakeOpenAI()
|
|
269
|
+
|
|
270
|
+
wrap(oa, client, task="task-a")
|
|
271
|
+
oa.responses.create(model="gpt-5-mini", input="first")
|
|
272
|
+
wrap(oa, client, task="task-b")
|
|
273
|
+
oa.responses.create(model="gpt-5-mini", input="second")
|
|
274
|
+
|
|
275
|
+
assert [r.task for r in captured] == ["task-a", "task-b"]
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def test_rewrapping_for_the_same_task_still_captures_once(monkeypatch: Any) -> None:
|
|
279
|
+
client = _client()
|
|
280
|
+
captured: list[Any] = []
|
|
281
|
+
monkeypatch.setattr(client, "post_trace_failopen", lambda record: captured.append(record))
|
|
282
|
+
oa = _FakeOpenAI()
|
|
283
|
+
|
|
284
|
+
wrap(oa, client, task="t")
|
|
285
|
+
wrap(oa, client, task="t")
|
|
286
|
+
oa.responses.create(model="gpt-5-mini", input="once")
|
|
287
|
+
|
|
288
|
+
assert len(captured) == 1
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|