runapprentice 0.4.0__tar.gz → 0.4.1__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.1}/PKG-INFO +1 -1
- {runapprentice-0.4.0 → runapprentice-0.4.1}/pyproject.toml +1 -1
- {runapprentice-0.4.0 → runapprentice-0.4.1}/src/runapprentice/client.py +11 -10
- {runapprentice-0.4.0 → runapprentice-0.4.1}/src/runapprentice/models.py +3 -0
- {runapprentice-0.4.0 → runapprentice-0.4.1}/src/runapprentice/openai.py +12 -7
- {runapprentice-0.4.0 → runapprentice-0.4.1}/tests/test_client.py +21 -0
- {runapprentice-0.4.0 → runapprentice-0.4.1}/tests/test_openai.py +36 -2
- {runapprentice-0.4.0 → runapprentice-0.4.1}/.gitignore +0 -0
- {runapprentice-0.4.0 → runapprentice-0.4.1}/LICENSE +0 -0
- {runapprentice-0.4.0 → runapprentice-0.4.1}/README.md +0 -0
- {runapprentice-0.4.0 → runapprentice-0.4.1}/src/runapprentice/__init__.py +0 -0
- {runapprentice-0.4.0 → runapprentice-0.4.1}/src/runapprentice/_capture.py +0 -0
- {runapprentice-0.4.0 → runapprentice-0.4.1}/src/runapprentice/cli.py +0 -0
- {runapprentice-0.4.0 → runapprentice-0.4.1}/src/runapprentice/langchain.py +0 -0
- {runapprentice-0.4.0 → runapprentice-0.4.1}/src/runapprentice/local.py +0 -0
- {runapprentice-0.4.0 → runapprentice-0.4.1}/src/runapprentice/py.typed +0 -0
- {runapprentice-0.4.0 → runapprentice-0.4.1}/tests/test_capture.py +0 -0
- {runapprentice-0.4.0 → runapprentice-0.4.1}/tests/test_cli.py +0 -0
- {runapprentice-0.4.0 → runapprentice-0.4.1}/tests/test_dispatcher.py +0 -0
- {runapprentice-0.4.0 → runapprentice-0.4.1}/tests/test_failopen.py +0 -0
- {runapprentice-0.4.0 → runapprentice-0.4.1}/tests/test_langchain_prompts.py +0 -0
- {runapprentice-0.4.0 → runapprentice-0.4.1}/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.1
|
|
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
|
|
@@ -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
|
|
@@ -164,18 +165,18 @@ class Apprentice:
|
|
|
164
165
|
return Job(self, JobInfo.model_validate(data))
|
|
165
166
|
|
|
166
167
|
def jobs(self, task: str) -> list[JobSummary]:
|
|
167
|
-
data = self._request("GET", f"/v1/tasks/{task}/jobs")
|
|
168
|
+
data = self._request("GET", f"/v1/tasks/{quote(task, safe='')}/jobs")
|
|
168
169
|
return [JobSummary.model_validate(item) for item in data]
|
|
169
170
|
|
|
170
171
|
def cancel_job(self, job_id: str) -> dict[str, Any]:
|
|
171
172
|
return dict(self._request("POST", f"/v1/jobs/{job_id}/cancel"))
|
|
172
173
|
|
|
173
174
|
def local_runs(self, task: str) -> list[LocalRun]:
|
|
174
|
-
data = self._request("GET", f"/v1/tasks/{task}/local-runs")
|
|
175
|
+
data = self._request("GET", f"/v1/tasks/{quote(task, safe='')}/local-runs")
|
|
175
176
|
return [LocalRun.model_validate(item) for item in data]
|
|
176
177
|
|
|
177
178
|
def train(self, task: str) -> Job:
|
|
178
|
-
data = self._request("POST", f"/v1/tasks/{task}/train", json={})
|
|
179
|
+
data = self._request("POST", f"/v1/tasks/{quote(task, safe='')}/train", json={})
|
|
179
180
|
return Job(self, JobInfo.model_validate(data))
|
|
180
181
|
|
|
181
182
|
def optimize_local(
|
|
@@ -441,13 +442,13 @@ class _DatasetsAPI:
|
|
|
441
442
|
payload_rows = [_dataset_row_payload(r, input_col, output_col) for r in rows]
|
|
442
443
|
data = self._client._request(
|
|
443
444
|
"POST",
|
|
444
|
-
f"/v1/datasets/{task}/rows",
|
|
445
|
+
f"/v1/datasets/{quote(task, safe='')}/rows",
|
|
445
446
|
json={"rows": payload_rows, "prompt": prompt},
|
|
446
447
|
)
|
|
447
448
|
return DatasetStatus.model_validate(data)
|
|
448
449
|
|
|
449
450
|
def status(self, task: str) -> DatasetStatus:
|
|
450
|
-
data = self._client._request("GET", f"/v1/datasets/{task}/status")
|
|
451
|
+
data = self._client._request("GET", f"/v1/datasets/{quote(task, safe='')}/status")
|
|
451
452
|
return DatasetStatus.model_validate(data)
|
|
452
453
|
|
|
453
454
|
def rows(self, task: str, tier: str, limit: int = 200) -> list[DatasetRow]:
|
|
@@ -456,7 +457,7 @@ class _DatasetsAPI:
|
|
|
456
457
|
while True:
|
|
457
458
|
data = self._client._request(
|
|
458
459
|
"GET",
|
|
459
|
-
f"/v1/datasets/{task}/rows?tier={tier}&limit={limit}&offset={offset}",
|
|
460
|
+
f"/v1/datasets/{quote(task, safe='')}/rows?tier={tier}&limit={limit}&offset={offset}",
|
|
460
461
|
)
|
|
461
462
|
batch = [DatasetRow.model_validate(item) for item in data.get("rows", [])]
|
|
462
463
|
rows.extend(batch)
|
|
@@ -481,21 +482,21 @@ class _PromptsAPI:
|
|
|
481
482
|
|
|
482
483
|
def register(self, task: str, template: Any) -> dict[str, Any]:
|
|
483
484
|
artifact = template if isinstance(template, dict) else _serialize_langchain_prompt(template)
|
|
484
|
-
return dict(self._client._request("POST", f"/v1/templates/{task}", json=artifact))
|
|
485
|
+
return dict(self._client._request("POST", f"/v1/templates/{quote(task, safe='')}", json=artifact))
|
|
485
486
|
|
|
486
487
|
def to_langchain(self, task: str | None = None, artifact: dict[str, Any] | None = None) -> Any:
|
|
487
488
|
if artifact is None:
|
|
488
489
|
if task is None:
|
|
489
490
|
raise ApprenticeError("provide task= or artifact=")
|
|
490
|
-
artifact = dict(self._client._request("GET", f"/v1/templates/{task}"))
|
|
491
|
+
artifact = dict(self._client._request("GET", f"/v1/templates/{quote(task, safe='')}"))
|
|
491
492
|
return _artifact_to_langchain(artifact)
|
|
492
493
|
|
|
493
494
|
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 "")
|
|
495
|
+
path = f"/v1/prompts/{quote(task, safe='')}" + (f"?version={version}" if version is not None else "")
|
|
495
496
|
return PromptVersion.model_validate(self._client._request("GET", path))
|
|
496
497
|
|
|
497
498
|
def history(self, task: str) -> list[PromptVersion]:
|
|
498
|
-
data = self._client._request("GET", f"/v1/prompts/{task}/history")
|
|
499
|
+
data = self._client._request("GET", f"/v1/prompts/{quote(task, safe='')}/history")
|
|
499
500
|
return [PromptVersion.model_validate(p) for p in data]
|
|
500
501
|
|
|
501
502
|
|
|
@@ -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:
|
|
@@ -283,6 +283,9 @@ def test_optimization_report_round_trips_every_api_field() -> None:
|
|
|
283
283
|
"baseline_score": 0.0,
|
|
284
284
|
"optimized_output": '{"total": 10}',
|
|
285
285
|
"optimized_score": 1.0,
|
|
286
|
+
# Distinguishes a 0 from a wrong answer and a 0 from a crashed row.
|
|
287
|
+
"baseline_error": None,
|
|
288
|
+
"optimized_error": "TimeoutError: judge call timed out",
|
|
286
289
|
}
|
|
287
290
|
],
|
|
288
291
|
"improved_count": 1,
|
|
@@ -774,3 +777,21 @@ def test_messages_needs_no_keywords_when_partials_fill_everything() -> None:
|
|
|
774
777
|
def test_messages_without_an_artifact_still_demands_inputs() -> None:
|
|
775
778
|
with pytest.raises(ValueError, match="keyword arguments"):
|
|
776
779
|
_prompt_version(artifact=None).messages()
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def test_task_names_with_url_delimiters_are_quoted() -> None:
|
|
783
|
+
"""The API accepts '/' and '?' in task names (main.py validates length only),
|
|
784
|
+
so an unquoted name reshapes the request path and the task becomes
|
|
785
|
+
unreachable from the SDK. httpx decodes `.path`, so assert on the raw target."""
|
|
786
|
+
seen: list[bytes] = []
|
|
787
|
+
|
|
788
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
789
|
+
seen.append(request.url.raw_path)
|
|
790
|
+
return httpx.Response(200, json={"task": "a/b?c", "gold": 0, "silver": 0, "raw": 0})
|
|
791
|
+
|
|
792
|
+
client = Apprentice(api_key="test", base_url="http://mock", capture_async=False)
|
|
793
|
+
client._http = httpx.Client(base_url="http://mock", transport=httpx.MockTransport(handler))
|
|
794
|
+
|
|
795
|
+
client.datasets.status("a/b?c")
|
|
796
|
+
|
|
797
|
+
assert seen[0] == b"/v1/datasets/a%2Fb%3Fc/status"
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|