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.
Files changed (22) hide show
  1. {runapprentice-0.4.0 → runapprentice-0.4.1}/PKG-INFO +1 -1
  2. {runapprentice-0.4.0 → runapprentice-0.4.1}/pyproject.toml +1 -1
  3. {runapprentice-0.4.0 → runapprentice-0.4.1}/src/runapprentice/client.py +11 -10
  4. {runapprentice-0.4.0 → runapprentice-0.4.1}/src/runapprentice/models.py +3 -0
  5. {runapprentice-0.4.0 → runapprentice-0.4.1}/src/runapprentice/openai.py +12 -7
  6. {runapprentice-0.4.0 → runapprentice-0.4.1}/tests/test_client.py +21 -0
  7. {runapprentice-0.4.0 → runapprentice-0.4.1}/tests/test_openai.py +36 -2
  8. {runapprentice-0.4.0 → runapprentice-0.4.1}/.gitignore +0 -0
  9. {runapprentice-0.4.0 → runapprentice-0.4.1}/LICENSE +0 -0
  10. {runapprentice-0.4.0 → runapprentice-0.4.1}/README.md +0 -0
  11. {runapprentice-0.4.0 → runapprentice-0.4.1}/src/runapprentice/__init__.py +0 -0
  12. {runapprentice-0.4.0 → runapprentice-0.4.1}/src/runapprentice/_capture.py +0 -0
  13. {runapprentice-0.4.0 → runapprentice-0.4.1}/src/runapprentice/cli.py +0 -0
  14. {runapprentice-0.4.0 → runapprentice-0.4.1}/src/runapprentice/langchain.py +0 -0
  15. {runapprentice-0.4.0 → runapprentice-0.4.1}/src/runapprentice/local.py +0 -0
  16. {runapprentice-0.4.0 → runapprentice-0.4.1}/src/runapprentice/py.typed +0 -0
  17. {runapprentice-0.4.0 → runapprentice-0.4.1}/tests/test_capture.py +0 -0
  18. {runapprentice-0.4.0 → runapprentice-0.4.1}/tests/test_cli.py +0 -0
  19. {runapprentice-0.4.0 → runapprentice-0.4.1}/tests/test_dispatcher.py +0 -0
  20. {runapprentice-0.4.0 → runapprentice-0.4.1}/tests/test_failopen.py +0 -0
  21. {runapprentice-0.4.0 → runapprentice-0.4.1}/tests/test_langchain_prompts.py +0 -0
  22. {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.0
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "runapprentice"
3
- version = "0.4.0"
3
+ version = "0.4.1"
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"
@@ -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
- Idempotent: wrapping an already-wrapped client is a no-op. Supports both sync
53
- (`OpenAI`) and async (`AsyncOpenAI`) clients.
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
- flag = f"_apprentice_wrapped_{method_name}"
91
- original = getattr(resource, method_name)
92
- if getattr(original, "_apprentice_wrapped", False) or getattr(resource, flag, False):
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._apprentice_wrapped = True # type: ignore[attr-defined]
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
- wrap(oa, _client(), task="t")
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, _client(), task="t")
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