runapprentice 0.4.2__tar.gz → 0.4.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {runapprentice-0.4.2 → runapprentice-0.4.3}/.gitignore +1 -0
- {runapprentice-0.4.2 → runapprentice-0.4.3}/PKG-INFO +1 -1
- {runapprentice-0.4.2 → runapprentice-0.4.3}/pyproject.toml +1 -1
- {runapprentice-0.4.2 → runapprentice-0.4.3}/src/runapprentice/cli.py +4 -1
- {runapprentice-0.4.2 → runapprentice-0.4.3}/src/runapprentice/client.py +5 -3
- {runapprentice-0.4.2 → runapprentice-0.4.3}/src/runapprentice/local.py +104 -31
- {runapprentice-0.4.2 → runapprentice-0.4.3}/src/runapprentice/models.py +16 -0
- {runapprentice-0.4.2 → runapprentice-0.4.3}/tests/test_cli.py +14 -13
- {runapprentice-0.4.2 → runapprentice-0.4.3}/tests/test_client.py +123 -0
- {runapprentice-0.4.2 → runapprentice-0.4.3}/LICENSE +0 -0
- {runapprentice-0.4.2 → runapprentice-0.4.3}/README.md +0 -0
- {runapprentice-0.4.2 → runapprentice-0.4.3}/src/runapprentice/__init__.py +0 -0
- {runapprentice-0.4.2 → runapprentice-0.4.3}/src/runapprentice/_capture.py +0 -0
- {runapprentice-0.4.2 → runapprentice-0.4.3}/src/runapprentice/langchain.py +0 -0
- {runapprentice-0.4.2 → runapprentice-0.4.3}/src/runapprentice/openai.py +0 -0
- {runapprentice-0.4.2 → runapprentice-0.4.3}/src/runapprentice/py.typed +0 -0
- {runapprentice-0.4.2 → runapprentice-0.4.3}/tests/test_capture.py +0 -0
- {runapprentice-0.4.2 → runapprentice-0.4.3}/tests/test_dispatcher.py +0 -0
- {runapprentice-0.4.2 → runapprentice-0.4.3}/tests/test_failopen.py +0 -0
- {runapprentice-0.4.2 → runapprentice-0.4.3}/tests/test_langchain_prompts.py +0 -0
- {runapprentice-0.4.2 → runapprentice-0.4.3}/tests/test_local.py +0 -0
- {runapprentice-0.4.2 → runapprentice-0.4.3}/tests/test_openai.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.3
|
|
4
4
|
Summary: Cut LLM cost without losing quality: capture traffic, build golden datasets, and optimize prompts.
|
|
5
5
|
Project-URL: Homepage, https://runapprentice.com
|
|
6
6
|
Project-URL: Documentation, https://docs.runapprentice.com
|
|
@@ -112,7 +112,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
112
112
|
optimize.add_argument(
|
|
113
113
|
"--local",
|
|
114
114
|
action="store_true",
|
|
115
|
-
help=
|
|
115
|
+
help=(
|
|
116
|
+
"run GEPA on this machine with your own OPENAI_API_KEY, no Apprentice account "
|
|
117
|
+
"needed; scores JSON extraction only, so every output must be a JSON object or array"
|
|
118
|
+
),
|
|
116
119
|
)
|
|
117
120
|
optimize.add_argument(
|
|
118
121
|
"--data",
|
|
@@ -156,7 +156,9 @@ class Apprentice:
|
|
|
156
156
|
) from exc
|
|
157
157
|
logger.debug("%s %s -> %s (%.0fms)", method, shown_path, resp.status_code, _ms(t0))
|
|
158
158
|
if resp.status_code >= 400:
|
|
159
|
-
logger.error(
|
|
159
|
+
logger.error(
|
|
160
|
+
"%s %s -> %s error=http_response", method, shown_path, resp.status_code
|
|
161
|
+
)
|
|
160
162
|
# The API sends a written reason in `detail`; lead with it so the first
|
|
161
163
|
# thing a reader sees is why the call failed. The route cannot tell us
|
|
162
164
|
# that: a 404 on /v1/prompts/{task} means the task has no optimized
|
|
@@ -430,8 +432,8 @@ class Apprentice:
|
|
|
430
432
|
self._capture_warned = False
|
|
431
433
|
return record.trace_id
|
|
432
434
|
if resp.status_code >= 500:
|
|
433
|
-
raise ApprenticeError(
|
|
434
|
-
self._warn_capture_once(f"backend rejected trace: {resp.status_code}
|
|
435
|
+
raise ApprenticeError("backend 5xx for /v1/traces")
|
|
436
|
+
self._warn_capture_once(f"backend rejected trace: {resp.status_code} for /v1/traces")
|
|
435
437
|
return None
|
|
436
438
|
|
|
437
439
|
def _warn_capture_once(self, message: str) -> None:
|
|
@@ -27,8 +27,10 @@ from runapprentice.client import ApprenticeError
|
|
|
27
27
|
from runapprentice.models import DatasetRow
|
|
28
28
|
|
|
29
29
|
LOCAL_MODEL_ID = "mlx-community/Qwen3.5-4B-OptiQ-4bit"
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
# Mirrors the API defaults (owner decision 2026-07-31). The exact id matters: the bare
|
|
31
|
+
# `gpt-5.6` alias routes to Sol, not Luna.
|
|
32
|
+
DEFAULT_STUDENT_MODEL = "openai/gpt-5.6-luna"
|
|
33
|
+
DEFAULT_REFLECTION_MODEL = "openai/gpt-5.6-luna"
|
|
32
34
|
|
|
33
35
|
# Every tier trains the SAME model quality: 16 LoRA layers (mlx-lm's default;
|
|
34
36
|
# fewer layers reduces final quality per LORA.md) and the same number of passes
|
|
@@ -117,18 +119,64 @@ def score_json_field_f1(row: DatasetRow, actual: str) -> float:
|
|
|
117
119
|
actual_obj = json.loads(_extract_json_text(actual))
|
|
118
120
|
except json.JSONDecodeError:
|
|
119
121
|
return 0.0
|
|
120
|
-
|
|
122
|
+
# Objects AND arrays, matching the API scorer: a task whose gold answers are top-level
|
|
123
|
+
# arrays scored 0 here for a correct answer.
|
|
124
|
+
if not isinstance(expected_obj, (dict, list)) or not isinstance(actual_obj, (dict, list)):
|
|
121
125
|
return 0.0
|
|
122
126
|
expected = _json_leaf_pairs(expected_obj)
|
|
123
127
|
actual_pairs = _json_leaf_pairs(actual_obj)
|
|
128
|
+
# Mirrors the hosted ceiling. Without it the same answer scores 1.0 here and 0.0 hosted,
|
|
129
|
+
# so a local run and a hosted run disagree about quality on identical data.
|
|
130
|
+
if len(expected) > _MAX_JSON_METRIC_LEAVES or len(actual_pairs) > _MAX_JSON_METRIC_LEAVES:
|
|
131
|
+
return 0.0
|
|
124
132
|
f1 = _leaf_f1(expected, actual_pairs)
|
|
125
|
-
if len(expected_obj) == 1:
|
|
133
|
+
if isinstance(expected_obj, dict) and isinstance(actual_obj, dict) and len(expected_obj) == 1:
|
|
126
134
|
root_key, root_value = next(iter(expected_obj.items()))
|
|
127
135
|
if isinstance(root_value, dict) and root_key not in actual_obj:
|
|
128
136
|
f1 = max(f1, _leaf_f1(_json_leaf_pairs(root_value), actual_pairs))
|
|
129
137
|
return f1
|
|
130
138
|
|
|
131
139
|
|
|
140
|
+
def _reject_unscoreable_rows(rows: Sequence[DatasetRow]) -> None:
|
|
141
|
+
"""Stop before spending if the scorer cannot read this data.
|
|
142
|
+
|
|
143
|
+
Local optimize scores with `score_json_field_f1` and nothing else -- the CLI refuses
|
|
144
|
+
`--metric` in local mode. That scorer returns 0.0 for an answer it cannot parse as a JSON
|
|
145
|
+
object or array, so a dataset of plain labels ("yes"/"no") used to run to completion on the
|
|
146
|
+
user's own OpenAI key and report 0.0 to 0.0: money and minutes spent measuring nothing.
|
|
147
|
+
The same shape of bug reached production hosted-side on 2026-08-02.
|
|
148
|
+
|
|
149
|
+
The test mirrors the scorer's own accept rule rather than restating it, so the two cannot
|
|
150
|
+
drift apart.
|
|
151
|
+
"""
|
|
152
|
+
bad = [
|
|
153
|
+
index
|
|
154
|
+
for index, row in enumerate(rows, start=1)
|
|
155
|
+
if not _is_json_container(row.output)
|
|
156
|
+
]
|
|
157
|
+
if not bad:
|
|
158
|
+
return
|
|
159
|
+
shown = ", ".join(str(index) for index in bad[:5])
|
|
160
|
+
more = f" (and {len(bad) - 5} more)" if len(bad) > 5 else ""
|
|
161
|
+
# One action, and it is true on every path that reaches this message. An earlier draft
|
|
162
|
+
# also offered "run without --local", which is false advice here: the hosted branch of
|
|
163
|
+
# `_optimize` never reads --data, so dropping the flag silently ignores the CSV and
|
|
164
|
+
# optimizes whatever rows already exist server-side. The CLI has no upload command, so
|
|
165
|
+
# there is no hosted path for THIS file to name.
|
|
166
|
+
raise ApprenticeError(
|
|
167
|
+
f"{len(bad)} of {len(rows)} rows have an output that is not a JSON object or array, "
|
|
168
|
+
f"starting at row {shown}{more}. Local optimize scores JSON extraction only, so these "
|
|
169
|
+
"rows would all score zero. Give each row's output column a JSON object or array."
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _is_json_container(output: str) -> bool:
|
|
174
|
+
try:
|
|
175
|
+
return isinstance(json.loads(output), (dict, list))
|
|
176
|
+
except json.JSONDecodeError:
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
|
|
132
180
|
def optimize_prompt_local(
|
|
133
181
|
rows: Sequence[DatasetRow],
|
|
134
182
|
*,
|
|
@@ -141,6 +189,7 @@ def optimize_prompt_local(
|
|
|
141
189
|
raise ApprenticeError("OPENAI_API_KEY is required for local prompt optimization")
|
|
142
190
|
if len(rows) < 2:
|
|
143
191
|
raise ApprenticeError("local optimize needs at least two rows for train and held-out scoring")
|
|
192
|
+
_reject_unscoreable_rows(rows)
|
|
144
193
|
dspy = _load_dspy()
|
|
145
194
|
student_model = student_model or os.environ.get("APPRENTICE_STUDENT_MODEL", DEFAULT_STUDENT_MODEL)
|
|
146
195
|
reflection_model = reflection_model or os.environ.get(
|
|
@@ -525,51 +574,75 @@ def _generate(row: DatasetRow, adapter_dir: Path, model_id: str) -> str:
|
|
|
525
574
|
|
|
526
575
|
|
|
527
576
|
def _extract_json_text(text: str) -> str:
|
|
577
|
+
# Objects AND arrays, mirroring the API. Scanning for "{" alone returned the first inner
|
|
578
|
+
# OBJECT of a wrapped array, so an array answer was compared as an object and scored 0.
|
|
528
579
|
candidate = text.strip()
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
580
|
+
openers = [(candidate.find(o), o, c) for o, c in (("{", "}"), ("[", "]")) if candidate.find(o) != -1]
|
|
581
|
+
for _, opener, closer in sorted(openers):
|
|
582
|
+
start = candidate.find(opener)
|
|
583
|
+
while start != -1:
|
|
584
|
+
in_string = False
|
|
585
|
+
escaped = False
|
|
586
|
+
depth = 0
|
|
587
|
+
for i, char in enumerate(candidate[start:], start=start):
|
|
588
|
+
if in_string:
|
|
589
|
+
if escaped:
|
|
590
|
+
escaped = False
|
|
591
|
+
elif char == "\\":
|
|
592
|
+
escaped = True
|
|
593
|
+
elif char == '"':
|
|
594
|
+
in_string = False
|
|
595
|
+
continue
|
|
596
|
+
if char == '"':
|
|
597
|
+
in_string = True
|
|
598
|
+
elif char == opener:
|
|
599
|
+
depth += 1
|
|
600
|
+
elif char == closer:
|
|
601
|
+
depth -= 1
|
|
602
|
+
if depth == 0:
|
|
603
|
+
span = candidate[start : i + 1]
|
|
604
|
+
try:
|
|
605
|
+
json.loads(span)
|
|
606
|
+
return span
|
|
607
|
+
except json.JSONDecodeError:
|
|
608
|
+
break
|
|
609
|
+
start = candidate.find(opener, start + 1)
|
|
552
610
|
return candidate
|
|
553
611
|
|
|
554
612
|
|
|
555
|
-
|
|
556
|
-
|
|
613
|
+
# Leaf kinds, mirroring the API. The kind is a separate field so an empty container can
|
|
614
|
+
# never be confused with a scalar that spells one.
|
|
615
|
+
# Mirrors apprentice-api optimizer._MAX_JSON_METRIC_LEAVES.
|
|
616
|
+
_MAX_JSON_METRIC_LEAVES = 2000
|
|
617
|
+
_LEAF_SCALAR = "scalar"
|
|
618
|
+
_LEAF_EMPTY_OBJECT = "empty-object"
|
|
619
|
+
_LEAF_EMPTY_ARRAY = "empty-array"
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _json_leaf_pairs(value: Any, path: str = "") -> set[tuple[str, str, str]]:
|
|
623
|
+
leaves: set[tuple[str, str, str]] = set()
|
|
557
624
|
|
|
558
625
|
def walk(node: Any, current_path: str) -> None:
|
|
559
626
|
if isinstance(node, dict):
|
|
627
|
+
if not node:
|
|
628
|
+
leaves.add((current_path or "$", _LEAF_EMPTY_OBJECT, ""))
|
|
629
|
+
return
|
|
560
630
|
for key in sorted(node):
|
|
561
631
|
walk(node[key], f"{current_path}.{key}" if current_path else str(key))
|
|
562
632
|
elif isinstance(node, list):
|
|
633
|
+
if not node:
|
|
634
|
+
leaves.add((current_path or "$", _LEAF_EMPTY_ARRAY, ""))
|
|
635
|
+
return
|
|
563
636
|
for i, item in enumerate(node):
|
|
564
637
|
walk(item, f"{current_path}[{i}]")
|
|
565
638
|
else:
|
|
566
|
-
leaves.add((current_path or "$", _normalize_scalar(node)))
|
|
639
|
+
leaves.add((current_path or "$", _LEAF_SCALAR, _normalize_scalar(node)))
|
|
567
640
|
|
|
568
641
|
walk(value, path)
|
|
569
642
|
return leaves
|
|
570
643
|
|
|
571
644
|
|
|
572
|
-
def _leaf_f1(expected: set[tuple[str, str]], actual: set[tuple[str, str]]) -> float:
|
|
645
|
+
def _leaf_f1(expected: set[tuple[str, str, str]], actual: set[tuple[str, str, str]]) -> float:
|
|
573
646
|
tp = len(expected & actual)
|
|
574
647
|
precision = tp / len(actual) if actual else 0.0
|
|
575
648
|
recall = tp / len(expected) if expected else 0.0
|
|
@@ -64,6 +64,9 @@ class HoldoutEvidenceRow(BaseModel):
|
|
|
64
64
|
class OptimizationReport(BaseModel):
|
|
65
65
|
task: str | None = None
|
|
66
66
|
baseline_score: float | None = None
|
|
67
|
+
# Set only when the baseline scored 0 on every held-out row, derived from the outputs it
|
|
68
|
+
# produced: output_not_structured | empty_output | evaluation_error | values_differ.
|
|
69
|
+
baseline_zero_reason: str | None = None
|
|
67
70
|
optimized_score: float | None = None
|
|
68
71
|
examples_used: int | None = None
|
|
69
72
|
baseline_prompt: str | None = None
|
|
@@ -85,7 +88,16 @@ class OptimizationReport(BaseModel):
|
|
|
85
88
|
judge_cache: dict[str, int] | None = None
|
|
86
89
|
score_explanation: str | None = None
|
|
87
90
|
worst_examples: list[dict[str, Any]] | None = None
|
|
91
|
+
# Deprecated, None on runs from 2026-08 onward: it counted rows scoring under 1.0, which
|
|
92
|
+
# on a continuous metric makes 0.97 a "failure". Older reports keep whatever they stored.
|
|
93
|
+
# Remove at 1.0.
|
|
88
94
|
failure_count: int | None = None
|
|
95
|
+
label_distribution: dict[str, int] | None = None
|
|
96
|
+
constant_baseline: dict[str, str | float] | None = None
|
|
97
|
+
constant_baseline_reason: str | None = None
|
|
98
|
+
per_class_result: list[dict[str, str | int]] | None = None
|
|
99
|
+
holdout_tier_counts: dict[str, int] | None = None
|
|
100
|
+
evaluation_errors: int | None = None
|
|
89
101
|
holdout_evidence: list[HoldoutEvidenceRow] | None = None
|
|
90
102
|
improved_count: int | None = None
|
|
91
103
|
regressed_count: int | None = None
|
|
@@ -149,6 +161,10 @@ class JobInfo(BaseModel):
|
|
|
149
161
|
error: str | None = None
|
|
150
162
|
report: SkipValidation[JobReport] | None = None
|
|
151
163
|
progress: dict[str, Any] | None = None
|
|
164
|
+
# Deprecated, always None: the API stopped reporting a queue rank because every rank it
|
|
165
|
+
# could compute was false (it ignored run_after and the worker's own --queues subset).
|
|
166
|
+
# Kept so `job.queue_position` still reads as "no rank" for anyone on 0.4.2 or earlier
|
|
167
|
+
# instead of raising AttributeError. Remove at 1.0.
|
|
152
168
|
queue_position: int | None = None
|
|
153
169
|
baseline_score: float | None = None
|
|
154
170
|
optimized_score: float | None = None
|
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import json
|
|
4
4
|
import os
|
|
5
5
|
import stat
|
|
6
|
+
import sys
|
|
6
7
|
from io import StringIO
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
from typing import Any, Callable
|
|
@@ -333,7 +334,7 @@ def test_login_writes_config_0600(
|
|
|
333
334
|
config_path = config_dir / "config.json"
|
|
334
335
|
monkeypatch.setattr(cli, "CONFIG_DIR", config_dir)
|
|
335
336
|
monkeypatch.setattr(cli, "CONFIG_PATH", config_path)
|
|
336
|
-
monkeypatch.setattr(
|
|
337
|
+
monkeypatch.setattr(sys.stdin, "isatty", lambda: True)
|
|
337
338
|
monkeypatch.setattr("runapprentice.cli.getpass.getpass", lambda _: "secret-key")
|
|
338
339
|
_mock_login_api(monkeypatch, lambda _request: httpx.Response(200, json=[]))
|
|
339
340
|
|
|
@@ -351,7 +352,7 @@ def test_login_rejects_invalid_key_without_writing(
|
|
|
351
352
|
) -> None:
|
|
352
353
|
config_path = tmp_path / "config.json"
|
|
353
354
|
monkeypatch.setattr(cli, "CONFIG_PATH", config_path)
|
|
354
|
-
monkeypatch.setattr(
|
|
355
|
+
monkeypatch.setattr(sys, "stdin", StringIO("invalid-key\n"))
|
|
355
356
|
_mock_login_api(
|
|
356
357
|
monkeypatch,
|
|
357
358
|
lambda _request: httpx.Response(401, json={"detail": "invalid API key"}),
|
|
@@ -369,7 +370,7 @@ def test_failed_login_does_not_clobber_existing_config(
|
|
|
369
370
|
original = '{"api_key": "working-key"}\n'
|
|
370
371
|
config_path.write_text(original, encoding="utf-8")
|
|
371
372
|
monkeypatch.setattr(cli, "CONFIG_PATH", config_path)
|
|
372
|
-
monkeypatch.setattr(
|
|
373
|
+
monkeypatch.setattr(sys, "stdin", StringIO("invalid-key\n"))
|
|
373
374
|
_mock_login_api(monkeypatch, lambda _request: httpx.Response(403, json={"detail": "forbidden"}))
|
|
374
375
|
|
|
375
376
|
assert cli.main(["login"]) == 1
|
|
@@ -380,7 +381,7 @@ def test_login_reports_unreachable_api_differently_from_rejected_key(
|
|
|
380
381
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
381
382
|
) -> None:
|
|
382
383
|
monkeypatch.setattr(cli, "CONFIG_PATH", tmp_path / "config.json")
|
|
383
|
-
monkeypatch.setattr(
|
|
384
|
+
monkeypatch.setattr(sys, "stdin", StringIO("candidate-key\n"))
|
|
384
385
|
|
|
385
386
|
def unreachable(request: httpx.Request) -> httpx.Response:
|
|
386
387
|
raise httpx.ConnectError("connection refused", request=request)
|
|
@@ -398,7 +399,7 @@ def test_login_reports_server_error_as_unavailable(
|
|
|
398
399
|
) -> None:
|
|
399
400
|
config_path = tmp_path / "config.json"
|
|
400
401
|
monkeypatch.setattr(cli, "CONFIG_PATH", config_path)
|
|
401
|
-
monkeypatch.setattr(
|
|
402
|
+
monkeypatch.setattr(sys, "stdin", StringIO("candidate-key\n"))
|
|
402
403
|
_mock_login_api(monkeypatch, lambda _request: httpx.Response(503))
|
|
403
404
|
|
|
404
405
|
assert cli.main(["login"]) == 1
|
|
@@ -425,7 +426,7 @@ def test_login_reads_piped_stdin_and_validates_it(
|
|
|
425
426
|
) -> None:
|
|
426
427
|
config_path = tmp_path / "config.json"
|
|
427
428
|
monkeypatch.setattr(cli, "CONFIG_PATH", config_path)
|
|
428
|
-
monkeypatch.setattr(
|
|
429
|
+
monkeypatch.setattr(sys, "stdin", StringIO(" piped-key \n"))
|
|
429
430
|
|
|
430
431
|
def validate(request: httpx.Request) -> httpx.Response:
|
|
431
432
|
assert request.url.path == "/v1/tasks"
|
|
@@ -547,7 +548,7 @@ def test_login_rejects_a_key_containing_whitespace(
|
|
|
547
548
|
"""
|
|
548
549
|
config_path = tmp_path / "config.json"
|
|
549
550
|
monkeypatch.setattr(cli, "CONFIG_PATH", config_path)
|
|
550
|
-
monkeypatch.setattr(
|
|
551
|
+
monkeypatch.setattr(sys, "stdin", StringIO("good-key\nJUNK-SECOND-LINE\n"))
|
|
551
552
|
|
|
552
553
|
def explode(_request: httpx.Request) -> httpx.Response:
|
|
553
554
|
raise AssertionError("a malformed key must never reach the network")
|
|
@@ -569,7 +570,7 @@ def test_login_directory_is_not_world_writable_under_a_permissive_umask(
|
|
|
569
570
|
"""
|
|
570
571
|
config_path = tmp_path / "nested" / "config.json"
|
|
571
572
|
monkeypatch.setattr(cli, "CONFIG_PATH", config_path)
|
|
572
|
-
monkeypatch.setattr(
|
|
573
|
+
monkeypatch.setattr(sys, "stdin", StringIO("good-key\n"))
|
|
573
574
|
_mock_login_api(monkeypatch, lambda _request: httpx.Response(200, json=[]))
|
|
574
575
|
|
|
575
576
|
previous = os.umask(0)
|
|
@@ -609,7 +610,7 @@ def test_login_rejects_a_key_that_is_not_printable_ascii(
|
|
|
609
610
|
"""
|
|
610
611
|
config_path = tmp_path / "config.json"
|
|
611
612
|
monkeypatch.setattr(cli, "CONFIG_PATH", config_path)
|
|
612
|
-
monkeypatch.setattr(
|
|
613
|
+
monkeypatch.setattr(sys, "stdin", StringIO(key + "\n"))
|
|
613
614
|
|
|
614
615
|
def explode(_request: httpx.Request) -> httpx.Response:
|
|
615
616
|
raise AssertionError("a malformed key must never reach the network")
|
|
@@ -632,7 +633,7 @@ def test_login_never_leaves_the_key_world_readable(
|
|
|
632
633
|
"""
|
|
633
634
|
config_path = tmp_path / "nested" / "config.json"
|
|
634
635
|
monkeypatch.setattr(cli, "CONFIG_PATH", config_path)
|
|
635
|
-
monkeypatch.setattr(
|
|
636
|
+
monkeypatch.setattr(sys, "stdin", StringIO("good-key\n"))
|
|
636
637
|
_mock_login_api(monkeypatch, lambda _request: httpx.Response(200, json=[]))
|
|
637
638
|
|
|
638
639
|
seen_modes: list[int] = []
|
|
@@ -643,7 +644,7 @@ def test_login_never_leaves_the_key_world_readable(
|
|
|
643
644
|
seen_modes.append(stat.S_IMODE(os.stat(src).st_mode))
|
|
644
645
|
real_replace(src, dst)
|
|
645
646
|
|
|
646
|
-
monkeypatch.setattr(
|
|
647
|
+
monkeypatch.setattr(os, "replace", spy)
|
|
647
648
|
|
|
648
649
|
previous = os.umask(0)
|
|
649
650
|
try:
|
|
@@ -663,7 +664,7 @@ def test_login_replaces_a_symlinked_config_instead_of_its_target(
|
|
|
663
664
|
config_path = tmp_path / "config.json"
|
|
664
665
|
config_path.symlink_to(victim)
|
|
665
666
|
monkeypatch.setattr(cli, "CONFIG_PATH", config_path)
|
|
666
|
-
monkeypatch.setattr(
|
|
667
|
+
monkeypatch.setattr(sys, "stdin", StringIO("good-key\n"))
|
|
667
668
|
_mock_login_api(monkeypatch, lambda _request: httpx.Response(200, json=[]))
|
|
668
669
|
|
|
669
670
|
assert cli.main(["login"]) == 0
|
|
@@ -683,7 +684,7 @@ def test_login_reports_a_filesystem_failure_instead_of_a_traceback(
|
|
|
683
684
|
"""
|
|
684
685
|
# A path whose parent cannot exist: /dev/null is a file, not a directory.
|
|
685
686
|
monkeypatch.setattr(cli, "CONFIG_PATH", Path("/dev/null/nope/config.json"))
|
|
686
|
-
monkeypatch.setattr(
|
|
687
|
+
monkeypatch.setattr(sys, "stdin", StringIO("good-key\n"))
|
|
687
688
|
_mock_login_api(monkeypatch, lambda _request: httpx.Response(200, json=[]))
|
|
688
689
|
|
|
689
690
|
assert cli.main(["--json", "login"]) == 1
|
|
@@ -252,10 +252,20 @@ def test_job_error_is_reachable() -> None:
|
|
|
252
252
|
assert job.error == "training failed"
|
|
253
253
|
|
|
254
254
|
|
|
255
|
+
def test_queue_position_still_reads_as_absent_after_the_api_stopped_sending_it() -> None:
|
|
256
|
+
"""0.4.2 published this attribute; dropping it would raise AttributeError on upgrade."""
|
|
257
|
+
info = JobInfo.model_validate({"job_id": "j1", "task": "t1", "kind": "optimize", "status": "queued"})
|
|
258
|
+
|
|
259
|
+
assert info.queue_position is None
|
|
260
|
+
|
|
261
|
+
|
|
255
262
|
def test_optimization_report_round_trips_every_api_field() -> None:
|
|
256
263
|
payload = {
|
|
257
264
|
"task": "extract",
|
|
258
265
|
"baseline_score": 41.0,
|
|
266
|
+
# Set only on a run whose baseline scored zero everywhere; carried here so this
|
|
267
|
+
# round-trip keeps covering every field the API can send.
|
|
268
|
+
"baseline_zero_reason": None,
|
|
259
269
|
"optimized_score": 88.0,
|
|
260
270
|
"examples_used": 20,
|
|
261
271
|
"baseline_prompt": "Extract fields.",
|
|
@@ -290,6 +300,15 @@ def test_optimization_report_round_trips_every_api_field() -> None:
|
|
|
290
300
|
{"input": "invoice", "expected": "{}", "actual": "{}", "score": 1.0}
|
|
291
301
|
],
|
|
292
302
|
"failure_count": 1,
|
|
303
|
+
"label_distribution": {"false": 12, "true": 4},
|
|
304
|
+
"constant_baseline": {"value": "false", "score": 75.0},
|
|
305
|
+
"constant_baseline_reason": None,
|
|
306
|
+
"per_class_result": [
|
|
307
|
+
{"label": "false", "correct": 12, "total": 12},
|
|
308
|
+
{"label": "true", "correct": 0, "total": 4},
|
|
309
|
+
],
|
|
310
|
+
"holdout_tier_counts": {"gold": 3, "silver": 13},
|
|
311
|
+
"evaluation_errors": 0,
|
|
293
312
|
"holdout_evidence": [
|
|
294
313
|
{
|
|
295
314
|
"row_id": "row-1",
|
|
@@ -632,6 +651,42 @@ def test_error_leads_with_the_reason_the_api_gave() -> None:
|
|
|
632
651
|
)
|
|
633
652
|
|
|
634
653
|
|
|
654
|
+
def test_http_error_log_omits_response_body(caplog: pytest.LogCaptureFixture) -> None:
|
|
655
|
+
secret = "customer SSN 123-45-6789"
|
|
656
|
+
client = Apprentice(api_key="k", base_url="http://mock", capture_async=False)
|
|
657
|
+
client._http = httpx.Client(
|
|
658
|
+
base_url="http://mock",
|
|
659
|
+
transport=httpx.MockTransport(
|
|
660
|
+
lambda _request: httpx.Response(422, json={"detail": secret})
|
|
661
|
+
),
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
with caplog.at_level(logging.ERROR, logger="runapprentice"):
|
|
665
|
+
with pytest.raises(ApprenticeError):
|
|
666
|
+
client._request("POST", "/v1/datasets/t1/rows", json={"input": secret})
|
|
667
|
+
|
|
668
|
+
assert "/v1/datasets/t1/rows" in caplog.text
|
|
669
|
+
assert "422" in caplog.text
|
|
670
|
+
assert secret not in caplog.text
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def test_capture_rejection_log_omits_response_body(caplog: pytest.LogCaptureFixture) -> None:
|
|
674
|
+
secret = "customer prompt in rejected body"
|
|
675
|
+
client = Apprentice(api_key="k", base_url="http://mock", capture_async=False)
|
|
676
|
+
client._http = httpx.Client(
|
|
677
|
+
base_url="http://mock",
|
|
678
|
+
transport=httpx.MockTransport(
|
|
679
|
+
lambda _request: httpx.Response(422, json={"detail": secret})
|
|
680
|
+
),
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
with caplog.at_level(logging.WARNING, logger="runapprentice"):
|
|
684
|
+
assert client.capture("t1", input="i", output="o") is None
|
|
685
|
+
|
|
686
|
+
assert "backend rejected trace: 422 for /v1/traces" in caplog.text
|
|
687
|
+
assert secret not in caplog.text
|
|
688
|
+
|
|
689
|
+
|
|
635
690
|
@pytest.mark.parametrize(
|
|
636
691
|
"body",
|
|
637
692
|
[["upstream failed"], "plain string", 42, None],
|
|
@@ -965,3 +1020,71 @@ def test_a_hostile_server_message_cannot_repaint_the_terminal() -> None:
|
|
|
965
1020
|
message = str(exc_info.value)
|
|
966
1021
|
assert "\x1b" not in message and "\x07" not in message and "\r" not in message
|
|
967
1022
|
assert "FAKE: login succeeded" in message # kept, but inert
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
def test_local_scorer_matches_the_hosted_one_on_arrays_and_empty_containers() -> None:
|
|
1026
|
+
"""local.py names the API scorer as its source of truth, so it has to keep up.
|
|
1027
|
+
|
|
1028
|
+
It rejected top-level arrays and ignored empty containers after the API stopped doing
|
|
1029
|
+
both, so the same answer scored differently depending on where it ran.
|
|
1030
|
+
"""
|
|
1031
|
+
from runapprentice.local import score_json_field_f1
|
|
1032
|
+
from runapprentice.models import DatasetRow
|
|
1033
|
+
|
|
1034
|
+
def score(expected: str, actual: str) -> float:
|
|
1035
|
+
return round(score_json_field_f1(DatasetRow(input="x", output=expected), actual), 3)
|
|
1036
|
+
|
|
1037
|
+
assert score("[1]", "[1]") == 1.0
|
|
1038
|
+
assert score('[{"a": 1}]', '[{"a": 1}]') == 1.0
|
|
1039
|
+
assert score('{"a": {}}', '{"a": {}}') == 1.0
|
|
1040
|
+
# A missing empty field is counted, not ignored.
|
|
1041
|
+
assert score('{"a": 1, "i": []}', '{"a": 1}') < 1.0
|
|
1042
|
+
# The leaf kind is not forgeable by a string that spells it.
|
|
1043
|
+
assert score('{"a": {}}', '{"a": "{}"}') == 0.0
|
|
1044
|
+
# Unchanged behaviour.
|
|
1045
|
+
assert score('{"a": 1, "b": 2}', '{"a": 1, "b": 2}') == 1.0
|
|
1046
|
+
assert score('{"a": 1, "b": 2}', '{"a": 1, "b": 9}') == 0.5
|
|
1047
|
+
assert score('{"root": {"a": 1}}', '{"a": 1}') == 1.0
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def test_local_optimize_refuses_data_its_scorer_cannot_read(
|
|
1051
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
1052
|
+
) -> None:
|
|
1053
|
+
"""Non-JSON rows must fail BEFORE any model call, not score 0.0 after paying.
|
|
1054
|
+
|
|
1055
|
+
Local optimize scores with score_json_field_f1 and the CLI refuses --metric in local
|
|
1056
|
+
mode, so a dataset of plain labels used to run to completion on the user's own OpenAI
|
|
1057
|
+
key and report 0.0 to 0.0. The guard has to fire before the key check does any work.
|
|
1058
|
+
"""
|
|
1059
|
+
from runapprentice import local
|
|
1060
|
+
from runapprentice.client import ApprenticeError
|
|
1061
|
+
from runapprentice.models import DatasetRow
|
|
1062
|
+
|
|
1063
|
+
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
|
|
1064
|
+
# Would blow up loudly if the guard let execution through to the optimizer.
|
|
1065
|
+
monkeypatch.setattr(local, "_load_dspy", lambda: pytest.fail("spent before validating"))
|
|
1066
|
+
|
|
1067
|
+
labels = [DatasetRow(input=f"email {i}", output="yes" if i % 2 else "no") for i in range(6)]
|
|
1068
|
+
with pytest.raises(ApprenticeError) as caught:
|
|
1069
|
+
local.optimize_prompt_local(labels)
|
|
1070
|
+
|
|
1071
|
+
message = str(caught.value)
|
|
1072
|
+
assert "6 of 6 rows" in message, "the count tells the user how much of their data is wrong"
|
|
1073
|
+
assert "not a JSON object or array" in message
|
|
1074
|
+
assert "output column a JSON object or array" in message, "one action, and a true one"
|
|
1075
|
+
# No hosted suggestion: the hosted branch never reads --data, so "run without --local"
|
|
1076
|
+
# would silently ignore this CSV.
|
|
1077
|
+
assert "--local" not in message
|
|
1078
|
+
|
|
1079
|
+
# A scalar is still not a container, and neither is a bare JSON string.
|
|
1080
|
+
with pytest.raises(ApprenticeError):
|
|
1081
|
+
local.optimize_prompt_local([DatasetRow(input="a", output="42")] * 2)
|
|
1082
|
+
with pytest.raises(ApprenticeError):
|
|
1083
|
+
local.optimize_prompt_local([DatasetRow(input="a", output='"yes"')] * 2)
|
|
1084
|
+
|
|
1085
|
+
# Objects and arrays both pass the guard: it must not narrow what the scorer accepts.
|
|
1086
|
+
mixed = [
|
|
1087
|
+
DatasetRow(input="a", output='{"label": "yes"}'),
|
|
1088
|
+
DatasetRow(input="b", output='[{"label": "no"}]'),
|
|
1089
|
+
]
|
|
1090
|
+
local._reject_unscoreable_rows(mixed)
|
|
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
|