driftless 0.2.7__tar.gz → 0.2.9__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 (89) hide show
  1. {driftless-0.2.7 → driftless-0.2.9}/CHANGELOG.md +24 -3
  2. {driftless-0.2.7 → driftless-0.2.9}/PKG-INFO +2 -2
  3. {driftless-0.2.7 → driftless-0.2.9}/README.md +1 -1
  4. {driftless-0.2.7 → driftless-0.2.9}/docs/RELEASE.md +4 -4
  5. {driftless-0.2.7 → driftless-0.2.9}/site/docs.html +1 -1
  6. {driftless-0.2.7 → driftless-0.2.9}/src/driftless/__init__.py +1 -1
  7. {driftless-0.2.7 → driftless-0.2.9}/src/driftless/contract.py +10 -0
  8. {driftless-0.2.7 → driftless-0.2.9}/src/driftless/harness.py +80 -41
  9. {driftless-0.2.7 → driftless-0.2.9}/tests/test_endpoint.py +45 -0
  10. driftless-0.2.9/tests/test_fetch_provider_models.py +111 -0
  11. {driftless-0.2.7 → driftless-0.2.9}/.gitignore +0 -0
  12. {driftless-0.2.7 → driftless-0.2.9}/LICENSE +0 -0
  13. {driftless-0.2.7 → driftless-0.2.9}/docs/repair-and-generators.md +0 -0
  14. {driftless-0.2.7 → driftless-0.2.9}/pyproject.toml +0 -0
  15. {driftless-0.2.7 → driftless-0.2.9}/site/assets/app.js +0 -0
  16. {driftless-0.2.7 → driftless-0.2.9}/site/assets/hero-workflow.png +0 -0
  17. {driftless-0.2.7 → driftless-0.2.9}/site/assets/landing.css +0 -0
  18. {driftless-0.2.7 → driftless-0.2.9}/site/assets/runs.css +0 -0
  19. {driftless-0.2.7 → driftless-0.2.9}/site/assets/runs.js +0 -0
  20. {driftless-0.2.7 → driftless-0.2.9}/site/assets/sample-run.json +0 -0
  21. {driftless-0.2.7 → driftless-0.2.9}/site/assets/styles.css +0 -0
  22. {driftless-0.2.7 → driftless-0.2.9}/site/index.html +0 -0
  23. {driftless-0.2.7 → driftless-0.2.9}/site/runs.html +0 -0
  24. {driftless-0.2.7 → driftless-0.2.9}/src/driftless/calibrate.py +0 -0
  25. {driftless-0.2.7 → driftless-0.2.9}/src/driftless/cli.py +0 -0
  26. {driftless-0.2.7 → driftless-0.2.9}/src/driftless/compare.py +0 -0
  27. {driftless-0.2.7 → driftless-0.2.9}/src/driftless/configure.py +0 -0
  28. {driftless-0.2.7 → driftless-0.2.9}/src/driftless/data/model_lifecycle.json +0 -0
  29. {driftless-0.2.7 → driftless-0.2.9}/src/driftless/datasource.py +0 -0
  30. {driftless-0.2.7 → driftless-0.2.9}/src/driftless/datastate.py +0 -0
  31. {driftless-0.2.7 → driftless-0.2.9}/src/driftless/discovery.py +0 -0
  32. {driftless-0.2.7 → driftless-0.2.9}/src/driftless/engine.py +0 -0
  33. {driftless-0.2.7 → driftless-0.2.9}/src/driftless/errors.py +0 -0
  34. {driftless-0.2.7 → driftless-0.2.9}/src/driftless/evaluation.py +0 -0
  35. {driftless-0.2.7 → driftless-0.2.9}/src/driftless/generators.py +0 -0
  36. {driftless-0.2.7 → driftless-0.2.9}/src/driftless/github.py +0 -0
  37. {driftless-0.2.7 → driftless-0.2.9}/src/driftless/init_ci.py +0 -0
  38. {driftless-0.2.7 → driftless-0.2.9}/src/driftless/judges.py +0 -0
  39. {driftless-0.2.7 → driftless-0.2.9}/src/driftless/label_audit.py +0 -0
  40. {driftless-0.2.7 → driftless-0.2.9}/src/driftless/lifecycle.py +0 -0
  41. {driftless-0.2.7 → driftless-0.2.9}/src/driftless/policy.py +0 -0
  42. {driftless-0.2.7 → driftless-0.2.9}/src/driftless/preflight.py +0 -0
  43. {driftless-0.2.7 → driftless-0.2.9}/src/driftless/progress.py +0 -0
  44. {driftless-0.2.7 → driftless-0.2.9}/src/driftless/report.py +0 -0
  45. {driftless-0.2.7 → driftless-0.2.9}/src/driftless/scanner.py +0 -0
  46. {driftless-0.2.7 → driftless-0.2.9}/src/driftless/splits.py +0 -0
  47. {driftless-0.2.7 → driftless-0.2.9}/src/driftless/templates.py +0 -0
  48. {driftless-0.2.7 → driftless-0.2.9}/src/driftless/view.py +0 -0
  49. {driftless-0.2.7 → driftless-0.2.9}/tests/fixtures/live_eval_baseline.json +0 -0
  50. {driftless-0.2.7 → driftless-0.2.9}/tests/fixtures/smoke/driftless.yml +0 -0
  51. {driftless-0.2.7 → driftless-0.2.9}/tests/fixtures/smoke/inputs.jsonl +0 -0
  52. {driftless-0.2.7 → driftless-0.2.9}/tests/fixtures/smoke/labels.jsonl +0 -0
  53. {driftless-0.2.7 → driftless-0.2.9}/tests/regression_metrics.py +0 -0
  54. {driftless-0.2.7 → driftless-0.2.9}/tests/scenarios.py +0 -0
  55. {driftless-0.2.7 → driftless-0.2.9}/tests/test_cli.py +0 -0
  56. {driftless-0.2.7 → driftless-0.2.9}/tests/test_compare.py +0 -0
  57. {driftless-0.2.7 → driftless-0.2.9}/tests/test_contract.py +0 -0
  58. {driftless-0.2.7 → driftless-0.2.9}/tests/test_data_change_gate.py +0 -0
  59. {driftless-0.2.7 → driftless-0.2.9}/tests/test_data_change_regression.py +0 -0
  60. {driftless-0.2.7 → driftless-0.2.9}/tests/test_datasource.py +0 -0
  61. {driftless-0.2.7 → driftless-0.2.9}/tests/test_datastate.py +0 -0
  62. {driftless-0.2.7 → driftless-0.2.9}/tests/test_discovery.py +0 -0
  63. {driftless-0.2.7 → driftless-0.2.9}/tests/test_engine.py +0 -0
  64. {driftless-0.2.7 → driftless-0.2.9}/tests/test_evaluation.py +0 -0
  65. {driftless-0.2.7 → driftless-0.2.9}/tests/test_extraction.py +0 -0
  66. {driftless-0.2.7 → driftless-0.2.9}/tests/test_generators.py +0 -0
  67. {driftless-0.2.7 → driftless-0.2.9}/tests/test_github.py +0 -0
  68. {driftless-0.2.7 → driftless-0.2.9}/tests/test_grading_loop.py +0 -0
  69. {driftless-0.2.7 → driftless-0.2.9}/tests/test_harness.py +0 -0
  70. {driftless-0.2.7 → driftless-0.2.9}/tests/test_init_ci.py +0 -0
  71. {driftless-0.2.7 → driftless-0.2.9}/tests/test_judge.py +0 -0
  72. {driftless-0.2.7 → driftless-0.2.9}/tests/test_judge_loop.py +0 -0
  73. {driftless-0.2.7 → driftless-0.2.9}/tests/test_label_audit.py +0 -0
  74. {driftless-0.2.7 → driftless-0.2.9}/tests/test_lifecycle.py +0 -0
  75. {driftless-0.2.7 → driftless-0.2.9}/tests/test_migration_live.py +0 -0
  76. {driftless-0.2.7 → driftless-0.2.9}/tests/test_migration_regression.py +0 -0
  77. {driftless-0.2.7 → driftless-0.2.9}/tests/test_plan_act.py +0 -0
  78. {driftless-0.2.7 → driftless-0.2.9}/tests/test_policy.py +0 -0
  79. {driftless-0.2.7 → driftless-0.2.9}/tests/test_poll_act.py +0 -0
  80. {driftless-0.2.7 → driftless-0.2.9}/tests/test_preflight.py +0 -0
  81. {driftless-0.2.7 → driftless-0.2.9}/tests/test_progress.py +0 -0
  82. {driftless-0.2.7 → driftless-0.2.9}/tests/test_refine.py +0 -0
  83. {driftless-0.2.7 → driftless-0.2.9}/tests/test_refresh_catalog.py +0 -0
  84. {driftless-0.2.7 → driftless-0.2.9}/tests/test_regression_metrics.py +0 -0
  85. {driftless-0.2.7 → driftless-0.2.9}/tests/test_repair_prompt.py +0 -0
  86. {driftless-0.2.7 → driftless-0.2.9}/tests/test_report.py +0 -0
  87. {driftless-0.2.7 → driftless-0.2.9}/tests/test_scanner.py +0 -0
  88. {driftless-0.2.7 → driftless-0.2.9}/tests/test_splits.py +0 -0
  89. {driftless-0.2.7 → driftless-0.2.9}/tests/test_view.py +0 -0
@@ -17,6 +17,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
17
17
 
18
18
  ---
19
19
 
20
+ ## [0.2.9] - 2026-07-01
21
+
22
+ ### Added
23
+
24
+ - **P5.2 endpoint concurrency** — optional `run.endpoint_concurrency` (1–32,
25
+ default 1) runs endpoint POSTs in parallel via `ThreadPoolExecutor`; output
26
+ line order always matches the input file.
27
+
28
+ ---
29
+
30
+ ## [0.2.8] - 2026-07-01
31
+
32
+ ### Added
33
+
34
+ - **P1.1 provider model discovery** — `tools/fetch_provider_models.py` queries
35
+ OpenAI and Anthropic `/models` APIs and emits new catalog entries only (never
36
+ overwrites lifecycle on existing ids). The scheduled `refresh-catalog.yml`
37
+ job merges discoveries when API keys are configured.
38
+
39
+ ---
40
+
20
41
  ## [0.2.7] - 2026-07-01
21
42
 
22
43
  ### Added
@@ -153,9 +174,9 @@ First public release on [PyPI](https://pypi.org/project/driftless/0.1.0/).
153
174
  - **Docs** — project overview, repair algorithm spec, 2×2 migration methodology,
154
175
  Poetry + Dependabot product framing.
155
176
 
156
- [Unreleased]: https://github.com/driftless-dev/driftless/compare/v0.2.7...HEAD
157
- [0.2.7]: https://github.com/driftless-dev/driftless/releases/tag/v0.2.7
158
- [0.2.6]: https://github.com/driftless-dev/driftless/compare/v0.2.6...v0.2.7
177
+ [Unreleased]: https://github.com/driftless-dev/driftless/compare/v0.2.9...HEAD
178
+ [0.2.9]: https://github.com/driftless-dev/driftless/releases/tag/v0.2.9
179
+ [0.2.8]: https://github.com/driftless-dev/driftless/compare/v0.2.8...v0.2.9
159
180
  [0.2.4]: https://github.com/driftless-dev/driftless/compare/v0.2.4...v0.2.5
160
181
  [0.2.3]: https://github.com/driftless-dev/driftless/compare/v0.2.3...v0.2.4
161
182
  [0.2.2]: https://github.com/driftless-dev/driftless/compare/v0.2.2...v0.2.3
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: driftless
3
- Version: 0.2.7
3
+ Version: 0.2.9
4
4
  Summary: Keep prompts in sync when model or eval data changes — Poetry-style lock regeneration, Dependabot-style PRs.
5
5
  Project-URL: Homepage, https://github.com/driftless-dev/driftless
6
6
  Project-URL: Repository, https://github.com/driftless-dev/driftless
@@ -133,7 +133,7 @@ can run in CI. See `.github/workflows/` for a scheduled deprecation scan, weekly
133
133
  `plan --act` triage, and manually-triggered migration workflows.
134
134
 
135
135
  ```yaml
136
- - uses: driftless-dev/driftless@v0.2.7
136
+ - uses: driftless-dev/driftless@v0.2.9
137
137
  with:
138
138
  command: scan
139
139
  ```
@@ -94,7 +94,7 @@ can run in CI. See `.github/workflows/` for a scheduled deprecation scan, weekly
94
94
  `plan --act` triage, and manually-triggered migration workflows.
95
95
 
96
96
  ```yaml
97
- - uses: driftless-dev/driftless@v0.2.7
97
+ - uses: driftless-dev/driftless@v0.2.9
98
98
  with:
99
99
  command: scan
100
100
  ```
@@ -153,7 +153,7 @@ After a release, users can pin the composite Action by release tag
153
153
  (`action.yml` lives at the repo root — no `/action` path segment):
154
154
 
155
155
  ```yaml
156
- - uses: driftless-dev/driftless@v0.2.7
156
+ - uses: driftless-dev/driftless@v0.2.9
157
157
  with:
158
158
  command: scan
159
159
  ```
@@ -161,9 +161,9 @@ After a release, users can pin the composite Action by release tag
161
161
  Or pin the PyPI package in the Action input:
162
162
 
163
163
  ```yaml
164
- - uses: driftless-dev/driftless@v0.2.7
164
+ - uses: driftless-dev/driftless@v0.2.9
165
165
  with:
166
- version: "==0.2.7"
166
+ version: "==0.2.9"
167
167
  command: migrate
168
168
  ```
169
169
 
@@ -171,7 +171,7 @@ Optionally maintain a floating **`v1`** tag on the latest stable minor release
171
171
  (point it at the current release tag after each publish):
172
172
 
173
173
  ```bash
174
- git tag -f v1 v0.2.7 && git push origin v1 --force
174
+ git tag -f v1 v0.2.9 && git push origin v1 --force
175
175
  ```
176
176
 
177
177
  Update [`action.yml`](../action.yml) default `version` input when cutting releases.
@@ -428,7 +428,7 @@ driftless view -w support_classifier</code></pre>
428
428
  <span class="tok-k">runs-on</span>: ubuntu-latest
429
429
  <span class="tok-k">steps</span>:
430
430
  - <span class="tok-k">uses</span>: actions/checkout@v4
431
- - <span class="tok-k">uses</span>: driftless-dev/driftless@v0.2.7
431
+ - <span class="tok-k">uses</span>: driftless-dev/driftless@v0.2.9
432
432
  <span class="tok-k">with</span>:
433
433
  <span class="tok-k">command</span>: <span class="tok-s">plan</span></code></pre>
434
434
  <p>A scheduled <code class="inline">plan</code> gates CI when a deprecated model needs attention; a manually-triggered <code class="inline">migrate</code> opens a PR (or an issue when blocked) with the evidence attached.</p>
@@ -1,3 +1,3 @@
1
1
  """driftless: Dependabot for LLM models."""
2
2
 
3
- __version__ = "0.2.7"
3
+ __version__ = "0.2.9"
@@ -68,6 +68,9 @@ class RunSpec(StrictModel):
68
68
  # the shell runner, which uses ``{{ model }}`` substitution / env_var).
69
69
  model_param: str | None = None
70
70
  timeout_seconds: int = 1800
71
+ # For endpoints: max parallel POSTs (1 = sequential). Output order always
72
+ # matches input order regardless of completion order.
73
+ endpoint_concurrency: int = 1
71
74
 
72
75
  @field_validator("command", "endpoint")
73
76
  @classmethod
@@ -76,6 +79,13 @@ class RunSpec(StrictModel):
76
79
  raise ValueError("must not be blank")
77
80
  return v
78
81
 
82
+ @field_validator("endpoint_concurrency")
83
+ @classmethod
84
+ def _endpoint_concurrency_range(cls, v: int) -> int:
85
+ if v < 1 or v > 32:
86
+ raise ValueError("run.endpoint_concurrency must be between 1 and 32")
87
+ return v
88
+
79
89
  @model_validator(mode="after")
80
90
  def _one_runner(self) -> "RunSpec":
81
91
  if not self.command and not self.endpoint:
@@ -16,6 +16,7 @@ import sys
16
16
  import time
17
17
  import urllib.error
18
18
  import urllib.request
19
+ from concurrent.futures import ThreadPoolExecutor
19
20
  from dataclasses import dataclass, field
20
21
  from pathlib import Path
21
22
 
@@ -198,6 +199,52 @@ def _read_jsonl(path: Path) -> list[dict]:
198
199
  return records
199
200
 
200
201
 
202
+ def _endpoint_post_record(
203
+ index: int,
204
+ rec: dict,
205
+ *,
206
+ endpoint: str,
207
+ model: str,
208
+ model_param: str,
209
+ headers: dict[str, str],
210
+ timeout: float,
211
+ id_field: str | None,
212
+ ) -> str:
213
+ """POST one input record and return the output JSONL line."""
214
+ body = dict(rec)
215
+ body[model_param] = model
216
+ try:
217
+ text = _http_post(
218
+ endpoint, json.dumps(body).encode("utf-8"), headers, timeout
219
+ )
220
+ except urllib.error.HTTPError as exc:
221
+ raise HarnessError(
222
+ f"endpoint returned HTTP {exc.code} on record {index}",
223
+ hint=_tail(exc.read().decode("utf-8", "replace")) if exc.fp else str(exc),
224
+ ) from exc
225
+ except (urllib.error.URLError, TimeoutError, OSError) as exc:
226
+ raise HarnessError(
227
+ f"endpoint request failed on record {index}: {endpoint}",
228
+ hint=str(getattr(exc, "reason", exc)),
229
+ ) from exc
230
+
231
+ try:
232
+ obj = json.loads(text)
233
+ except json.JSONDecodeError as exc:
234
+ raise HarnessError(
235
+ f"endpoint returned non-JSON on record {index}",
236
+ hint=_tail(text) or "expected a JSON object per record",
237
+ ) from exc
238
+ if not isinstance(obj, dict):
239
+ raise HarnessError(
240
+ f"endpoint response on record {index} must be a JSON object",
241
+ hint=f"got {type(obj).__name__}",
242
+ )
243
+ if id_field and id_field not in obj and id_field in rec:
244
+ obj[id_field] = rec[id_field]
245
+ return json.dumps(obj)
246
+
247
+
201
248
  def _run_endpoint(
202
249
  workflow: Workflow, model: str, *, cwd: Path, output_path: Path
203
250
  ) -> RunResult:
@@ -207,7 +254,8 @@ def _run_endpoint(
207
254
  ``run.model_param`` (default ``"model"``); the JSON response object is written
208
255
  as the corresponding output record. When ``eval.id_field`` is set and the
209
256
  response omits it, the input's id is copied through so output<->label
210
- alignment still works.
257
+ alignment still works. Use ``run.endpoint_concurrency`` (>1) to POST in
258
+ parallel; output line order always matches the input file.
211
259
  """
212
260
  run = workflow.run
213
261
  input_path = (cwd / run.input_path).resolve()
@@ -225,48 +273,39 @@ def _run_endpoint(
225
273
  if token:
226
274
  headers["Authorization"] = f"Bearer {token}"
227
275
 
228
- out_lines: list[str] = []
276
+ endpoint = run.endpoint
277
+ if endpoint is None:
278
+ raise HarnessError(
279
+ "no endpoint URL is configured",
280
+ hint="set run.endpoint in the contract",
281
+ )
282
+
283
+ concurrency = run.endpoint_concurrency
284
+ timeout = float(run.timeout_seconds)
229
285
  start = time.monotonic()
230
- for i, rec in enumerate(records, start=1):
231
- body = dict(rec)
232
- body[model_param] = model
233
- endpoint = run.endpoint
234
- if endpoint is None:
235
- raise HarnessError(
236
- "no endpoint URL is configured",
237
- hint="set run.endpoint in the contract",
238
- )
239
- try:
240
- text = _http_post(
241
- endpoint, json.dumps(body).encode("utf-8"), headers, run.timeout_seconds
242
- )
243
- except urllib.error.HTTPError as exc:
244
- raise HarnessError(
245
- f"endpoint returned HTTP {exc.code} on record {i}",
246
- hint=_tail(exc.read().decode("utf-8", "replace")) if exc.fp else str(exc),
247
- ) from exc
248
- except (urllib.error.URLError, TimeoutError, OSError) as exc:
249
- raise HarnessError(
250
- f"endpoint request failed on record {i}: {run.endpoint}",
251
- hint=str(getattr(exc, "reason", exc)),
252
- ) from exc
253
286
 
254
- try:
255
- obj = json.loads(text)
256
- except json.JSONDecodeError as exc:
257
- raise HarnessError(
258
- f"endpoint returned non-JSON on record {i}",
259
- hint=_tail(text) or "expected a JSON object per record",
260
- ) from exc
261
- if not isinstance(obj, dict):
262
- raise HarnessError(
263
- f"endpoint response on record {i} must be a JSON object",
264
- hint=f"got {type(obj).__name__}",
265
- )
266
- if id_field and id_field not in obj and id_field in rec:
267
- obj[id_field] = rec[id_field]
268
- out_lines.append(json.dumps(obj))
287
+ def post_one(index: int, rec: dict) -> tuple[int, str]:
288
+ line = _endpoint_post_record(
289
+ index,
290
+ rec,
291
+ endpoint=endpoint,
292
+ model=model,
293
+ model_param=model_param,
294
+ headers=headers,
295
+ timeout=timeout,
296
+ id_field=id_field,
297
+ )
298
+ return index, line
299
+
300
+ if concurrency <= 1 or len(records) <= 1:
301
+ indexed_lines = [post_one(i, rec) for i, rec in enumerate(records, start=1)]
302
+ else:
303
+ workers = min(concurrency, len(records))
304
+ pairs = [(i, rec) for i, rec in enumerate(records, start=1)]
305
+ with ThreadPoolExecutor(max_workers=workers) as pool:
306
+ indexed_lines = list(pool.map(lambda pair: post_one(pair[0], pair[1]), pairs))
269
307
 
308
+ out_lines = [line for _, line in indexed_lines]
270
309
  duration = time.monotonic() - start
271
310
  output_path.write_text("\n".join(out_lines) + "\n", encoding="utf-8")
272
311
  return RunResult(
@@ -274,7 +313,7 @@ def _run_endpoint(
274
313
  output_path=output_path,
275
314
  returncode=0,
276
315
  duration_seconds=duration,
277
- stdout=f"{len(out_lines)} records via {run.endpoint}",
316
+ stdout=f"{len(out_lines)} records via {endpoint} (concurrency={concurrency})",
278
317
  stderr="",
279
318
  env_overrides={},
280
319
  )
@@ -118,3 +118,48 @@ def test_run_rejects_both_command_and_endpoint():
118
118
  RunSpec.model_validate(
119
119
  {"command": "echo hi", "endpoint": "http://x", "input_path": "i", "output_path": "o"}
120
120
  )
121
+
122
+
123
+ def test_endpoint_concurrency_must_be_in_range():
124
+ with pytest.raises(ValueError, match="endpoint_concurrency"):
125
+ RunSpec.model_validate(
126
+ {
127
+ "endpoint": "http://x",
128
+ "input_path": "i",
129
+ "output_path": "o",
130
+ "endpoint_concurrency": 0,
131
+ }
132
+ )
133
+
134
+
135
+ def test_endpoint_concurrency_preserves_order_and_parallelizes(tmp_path, monkeypatch):
136
+ import threading
137
+ import time
138
+
139
+ records = [{"id": str(i), "text": "x"} for i in range(8)]
140
+ _write_inputs(tmp_path, records)
141
+ lock = threading.Lock()
142
+ in_flight = 0
143
+ max_in_flight = 0
144
+
145
+ def fake_post(url, payload, headers, timeout):
146
+ nonlocal in_flight, max_in_flight
147
+ body = json.loads(payload.decode("utf-8"))
148
+ with lock:
149
+ in_flight += 1
150
+ max_in_flight = max(max_in_flight, in_flight)
151
+ time.sleep(0.03)
152
+ with lock:
153
+ in_flight -= 1
154
+ return json.dumps({"id": body["id"], "label": "ok"})
155
+
156
+ monkeypatch.setattr(harness, "_http_post", fake_post)
157
+ result = run_workflow(
158
+ _endpoint_workflow(endpoint_concurrency=4), "m1", cwd=tmp_path
159
+ )
160
+
161
+ assert result.ok
162
+ assert max_in_flight >= 3
163
+ rows = _read_out(result.output_path)
164
+ assert [r["id"] for r in rows] == [str(i) for i in range(8)]
165
+ assert "concurrency=4" in result.stdout
@@ -0,0 +1,111 @@
1
+ import json
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "tools"))
8
+
9
+ import fetch_provider_models as fpm # noqa: E402
10
+
11
+
12
+ def _catalog(models) -> Path:
13
+ import tempfile
14
+
15
+ p = Path(tempfile.mkdtemp()) / "cat.json"
16
+ p.write_text(json.dumps({"models": models}), encoding="utf-8")
17
+ return p
18
+
19
+
20
+ def test_discover_new_models_skips_known_and_filters_openai(tmp_path):
21
+ cat = _catalog(
22
+ [
23
+ {"model": "gpt-4o", "provider": "openai"},
24
+ {"model": "claude-3-5-sonnet", "provider": "anthropic"},
25
+ ]
26
+ )
27
+
28
+ def fake_fetch(_key):
29
+ return [
30
+ "gpt-4o", # known
31
+ "gpt-5-mini", # new
32
+ "ft:gpt-4o:org:123", # fine-tune — skip
33
+ "tts-1", # infra — skip
34
+ "whisper-1",
35
+ ]
36
+
37
+ updates = fpm.discover_new_models(
38
+ provider="openai",
39
+ catalog_path=cat,
40
+ fetch_ids=fake_fetch,
41
+ keep=fpm._keep_openai,
42
+ api_key="k",
43
+ )
44
+ assert [u["model"] for u in updates] == ["gpt-5-mini"]
45
+ assert updates[0]["status"] == "active"
46
+
47
+
48
+ def test_discover_new_models_anthropic_claude_only(tmp_path):
49
+ cat = _catalog([{"model": "claude-3-5-sonnet", "provider": "anthropic"}])
50
+
51
+ updates = fpm.discover_new_models(
52
+ provider="anthropic",
53
+ catalog_path=cat,
54
+ fetch_ids=lambda _k: ["claude-3-5-sonnet", "claude-3-7-sonnet", "not-a-model"],
55
+ keep=fpm._keep_anthropic,
56
+ api_key="k",
57
+ )
58
+ assert [u["model"] for u in updates] == ["claude-3-7-sonnet"]
59
+
60
+
61
+ def test_fetch_updates_merges_providers_and_skips_missing_keys(tmp_path, monkeypatch):
62
+ cat = _catalog([{"model": "gpt-4o", "provider": "openai"}])
63
+ monkeypatch.delenv("OPENAI_API_KEY", raising=False)
64
+ monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
65
+
66
+ updates = fpm.fetch_updates(["openai", "anthropic"], catalog_path=cat)
67
+ assert updates == []
68
+
69
+
70
+ def test_fetch_updates_openai(monkeypatch, tmp_path):
71
+ cat = _catalog([{"model": "gpt-4o", "provider": "openai"}])
72
+ monkeypatch.setenv("OPENAI_API_KEY", "sekret")
73
+ monkeypatch.setattr(
74
+ fpm,
75
+ "_openai_model_ids",
76
+ lambda key: (["gpt-4o", "o3-mini"] if key == "sekret" else []),
77
+ )
78
+
79
+ updates = fpm.fetch_updates(["openai"], catalog_path=cat)
80
+ assert [u["model"] for u in updates] == ["o3-mini"]
81
+
82
+
83
+ def test_cli_writes_output(tmp_path, monkeypatch):
84
+ cat = tmp_path / "cat.json"
85
+ cat.write_text(json.dumps({"models": []}), encoding="utf-8")
86
+ out = tmp_path / "updates.json"
87
+ monkeypatch.setattr(
88
+ fpm,
89
+ "fetch_updates",
90
+ lambda providers, catalog_path: [
91
+ {"model": "gpt-5", "provider": "openai", "status": "active"}
92
+ ],
93
+ )
94
+ assert fpm.main(["--provider", "openai", "--catalog", str(cat), "-o", str(out)]) == 0
95
+ data = json.loads(out.read_text(encoding="utf-8"))
96
+ assert data[0]["model"] == "gpt-5"
97
+
98
+
99
+ def test_http_get_json_raises_on_http_error(monkeypatch):
100
+ import urllib.error
101
+
102
+ class FakeHTTPError(urllib.error.HTTPError):
103
+ def __init__(self):
104
+ super().__init__(url="http://x", code=401, msg="nope", hdrs={}, fp=None)
105
+
106
+ def boom(*a, **k):
107
+ raise FakeHTTPError()
108
+
109
+ monkeypatch.setattr(fpm.urllib.request, "urlopen", boom)
110
+ with pytest.raises(RuntimeError, match="HTTP 401"):
111
+ fpm._http_get_json("http://x", {})
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