lastorders 0.4.0__py3-none-any.whl

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.
@@ -0,0 +1,212 @@
1
+ Metadata-Version: 2.4
2
+ Name: lastorders
3
+ Version: 0.4.0
4
+ Summary: Free scanner for retiring AI model IDs (OpenAI, Anthropic, ...) in your code, with CI support.
5
+ Author: Burnt & Toast Studios
6
+ Project-URL: Homepage, https://burntandtoast.com
7
+ Keywords: openai,anthropic,llm,deprecation,migration,gpt-4o,scanner,github-action
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Software Development :: Quality Assurance
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ Requires-Dist: pyyaml>=6
20
+ Requires-Dist: jsonschema>=4
21
+ Requires-Dist: libcst>=1.1
22
+ Requires-Dist: cryptography>=41
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest>=8; extra == "dev"
25
+ Requires-Dist: build>=1; extra == "dev"
26
+
27
+ # Last Orders (working name)
28
+
29
+ **Find retiring AI model IDs in your code before they break.** One command scans your repo for OpenAI
30
+ (and, as a preview, Anthropic) model IDs that are being shut down. For each one it tells you the
31
+ shutdown date, the replacement the provider recommends, and the code changes the new model needs.
32
+
33
+ > **Heads-up:** OpenAI removes **GPT-4o, GPT-4o mini, GPT-4.1 and GPT-4.1 mini** from the API on
34
+ > **14 January 2027** and recommends `gpt-6-luna`. In our tests, `gpt-6-luna` rejects `max_tokens`, and it
35
+ > rejects tool calls on Chat Completions unless `reasoning_effort` is `"none"`. So changing the model name
36
+ > isn't enough. The scan flags both.
37
+
38
+ The scan is free. You don't need an account, a licence or an API key, and your code never leaves your machine.
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ pipx install lastorders # or: pip install lastorders
44
+ ```
45
+
46
+ You need Python 3.10 or later.
47
+
48
+ ## Scan
49
+
50
+ ```bash
51
+ lastorders scan . # scan the current repo
52
+ lastorders scan path/to/repo --out scan-results
53
+ lastorders scan . --offline # no network at all (uses the bundled migration pack)
54
+ ```
55
+
56
+ You get `scan.md`, a readable report, and `scan.json`, the same data for tools. They go in `lastorders-out/`, or wherever `--out` points. Each finding includes:
57
+
58
+ - **File, line and model ID**, from code (Python, JS, TS), `.env` files, YAML, JSON, TOML and docs
59
+ - **Shutdown date and recommended replacement**, from the provider's published deprecations
60
+ - **Risk level**. High means the date is close or has passed, or the new model needs a code change your
61
+ code doesn't have yet, such as `max_tokens` becoming `max_completion_tokens`, `reasoning_effort` for tools
62
+ and `temperature`, or an unsupported endpoint
63
+ - **Fine-tuned models** (`ft:...`), listed separately. They shut down with their base model and can't be
64
+ swapped automatically.
65
+ - **Models chosen by environment variable** (`os.getenv("OPENAI_MODEL")`), so you can check the
66
+ values you have deployed
67
+ - **SDKs detected**: the official `openai` and `anthropic` clients, LangChain and plain HTTP
68
+
69
+ Useful flags:
70
+
71
+ | Flag | What it does |
72
+ |---|---|
73
+ | `--fail-on-high` | exit code 2 if anything is high risk (for CI) |
74
+ | `--no-docs` | skip `.md` / `.txt` files |
75
+ | `--pack openai` | only use some providers' packs (default: all) |
76
+ | `--offline` | use the bundled pack only; makes no network calls |
77
+ | `--format json` | print the summary as JSON |
78
+ | `--summary-file F` / `--annotations` | CI outputs (used by the GitHub Action) |
79
+
80
+ ## Fix (patch preview)
81
+
82
+ ```bash
83
+ lastorders fix . # writes lastorders-out/fix.diff + fix-plan.md; changes nothing on disk
84
+ lastorders fix . --verify # also checks the patch on a scratch copy (see below)
85
+ git apply lastorders-out/fix.diff
86
+ ```
87
+
88
+ `fix` swaps the retiring model IDs and then makes the code changes the new model needs at each API call:
89
+
90
+ - **Renames `max_tokens`**, to `max_completion_tokens` on Chat Completions or `max_output_tokens` on Responses.
91
+ - **Adds `reasoning_effort: "none"`** where gpt-6-luna needs it: Chat Completions calls with tools, and
92
+ calls that set `temperature`, `top_p` or `logprobs`. On Responses it adds `reasoning.effort`.
93
+ - **Flags anything it can't decide safely** in `fix-plan.md`: legacy `functions` calls, Assistants and legacy
94
+ Completions calls (these stay on their current model in the patch), fine-tunes and fine-tuning jobs,
95
+ LangChain wrappers, calls that pass `**kwargs` or a spread object, conflicting values, and calls whose model
96
+ isn't visible in the code (use `--assume-model`).
97
+
98
+ Every change comes from the migration pack's rules. Python is edited with
99
+ [libcst](https://github.com/Instagram/LibCST), so your formatting and comments are kept. JS and TS are edited
100
+ with a built-in parser. Rules the pack marks `inferred` are only suggested unless you pass `--include-inferred`.
101
+
102
+ **Old models reject `reasoning_effort`.** We tested gpt-4o and gpt-4.1 against the live API, and both return
103
+ HTTP 400. Any fix that adds it has to ship together with the model switch. When the model comes from an
104
+ environment variable, the plan marks the fix "ship with the model switch". `--no-model-swap` applies only
105
+ the changes that are safe on the old model.
106
+
107
+ `--verify` applies the patch to a temporary copy of your repo, then:
108
+
109
+ 1. checks the changed files still parse;
110
+ 2. re-runs the fixer and checks it changes nothing more;
111
+ 3. replays each fixed call twice: once as it was (old model, old arguments) and once as patched (new model,
112
+ new arguments).
113
+
114
+ Without `--live`, the replay uses simulated responses. With `--live` and your own `OPENAI_API_KEY` (licence),
115
+ the calls go straight to OpenAI. A control run also sends the unpatched arguments to the new model, to show
116
+ each fix was needed. `--evals evals/regression.yaml` reruns your own suite with the fixed arguments.
117
+
118
+ ## GitHub Action
119
+
120
+ Add `.github/workflows/model-scan.yml`:
121
+
122
+ ```yaml
123
+ name: Model retirement scan
124
+ on:
125
+ pull_request:
126
+ schedule: [{ cron: "0 7 * * 1" }] # weekly, so new deprecations show up without code changes
127
+ permissions:
128
+ contents: read
129
+ jobs:
130
+ scan:
131
+ runs-on: ubuntu-latest
132
+ steps:
133
+ - uses: actions/checkout@v4
134
+ - uses: burntandtoast/lastorders@v0 # available once the repo is published
135
+ with:
136
+ fail-on-high: "true"
137
+ ```
138
+
139
+ The Action posts a summary table to the job page, marks findings inline on the PR and uploads `scan.json`
140
+ as an artifact. Inputs:
141
+
142
+ | Input | Default | |
143
+ |---|---|---|
144
+ | `path` | `.` | folder to scan |
145
+ | `fail-on-high` | `false` | fail the job on high-risk findings |
146
+ | `include-docs` | `true` | scan `.md`/`.txt` |
147
+ | `annotations` | `true` | inline PR annotations |
148
+ | `packs` | all | e.g. `openai,anthropic` |
149
+ | `offline` | `false` | bundled pack only, no network |
150
+ | `packs-url` | built-in | override the pack URL |
151
+ | `version` | action source | install this version from PyPI instead |
152
+ | `python-version` | `3.12` | |
153
+ | `fix-preview` | `false` | also attach the automatic-fix patch + plan (simulated verify, no key) |
154
+ | `artifact-name` | `lastorders-scan` | |
155
+
156
+ Outputs: `total`, `high` and `report-dir`.
157
+
158
+ ## Migration packs: where the data comes from
159
+
160
+ What the scanner knows lives in **migration packs**, versioned JSON files with one pack per provider. Each pack
161
+ holds the model IDs and aliases, shutdown dates, replacements, call-site patterns, and parameter and endpoint
162
+ rules. Every fact links to the provider's documentation and the date we checked it. Rules we confirmed
163
+ against the live API are marked `live-tested`, and anything we assumed is marked `inferred`.
164
+
165
+ - **OpenAI**: full pack
166
+ - **Anthropic**: preview (stub). It covers Claude API retirement dates from Anthropic's deprecations page.
167
+ Amazon Bedrock and Google Cloud dates are not covered yet.
168
+ - Gemini, Azure OpenAI and Bedrock: planned
169
+
170
+ The tool checks for a newer pack once a day and falls back to the copy bundled with the package.
171
+ `lastorders packs list` shows which version and source you're using. The format is documented in
172
+ [docs/PACKS.md](docs/PACKS.md), with a JSON Schema in `migration_kit/pack_data/pack.schema.json`.
173
+
174
+ ## Privacy and network use
175
+
176
+ - The scan runs **entirely on your machine**. Your code and prompts are never uploaded.
177
+ - **The tool has no telemetry, analytics or tracking.** (Our website counts visits anonymously with Cloudflare Web Analytics: no cookies, no ads, no tracking across sites.)
178
+ - The tool only ever contacts us to **download the latest migration pack** (a plain GET of a static JSON file,
179
+ with no identifiers) and, for paid features, to **check a licence key**. That check sends only the key and the
180
+ repo name (`github.com/owner/repo`), and the signed answer is cached for a week, with a 14-day offline grace period after that.
181
+ - `--offline` (or `LASTORDERS_OFFLINE=1`) turns off all network access and uses the bundled pack.
182
+ - The paid `eval` command calls the AI provider directly **with your own API key**. Nothing goes through us.
183
+
184
+ ## Paid features
185
+
186
+ | | Free | Licence (per repo) | Done for you |
187
+ |---|---|---|---|
188
+ | `scan`, GitHub Action, patch preview (`fix` / `swap` write a `.diff`), `fix --verify` (simulated replay) | ✓ | ✓ | ✓ |
189
+ | `fix --apply` / `swap --apply`, `fix --verify --live`, `init-evals` + `eval` regression testing, client-ready `report` | | ✓ | ✓ |
190
+ | We do the migration, evals and handoff | | | ✓ |
191
+
192
+ One licence covers **one GitHub repository, monorepos included**. It's bound to the repo the first time
193
+ you use it. Buy one with `lastorders licence buy` (it prints a Stripe Checkout link for this repo), then pass the key
194
+ with `LASTORDERS_LICENCE` (a repository secret in Actions) or `--licence`. `lastorders licence status` shows
195
+ whether it's verified. Paid commands work `--offline` from the cached check. Repos without an `origin` remote
196
+ can set `LASTORDERS_REPO=github.com/owner/repo`. The licence server lives in `server/`; see
197
+ [docs/LICENCE-SERVER.md](docs/LICENCE-SERVER.md).
198
+
199
+ `init-evals` reads the call sites the scan found, including prompts, JSON schemas and tool definitions where
200
+ they can be read from the code, and writes a starter `evals/regression.yaml`. You then edit it into 10 to 20
201
+ real examples. `eval` runs them against the old and new model with your own key.
202
+
203
+ ## Limitations
204
+
205
+ - Detection uses pattern matching. Model names built at runtime or stored in a database won't be found;
206
+ env-var lookups are listed so you can check them.
207
+ - A pack is only as current as its `updated` date. Always check the provider's documentation before acting.
208
+ - The scan suggests changes; it doesn't decide them for you. Review every patch.
209
+
210
+ ---
211
+
212
+ Made by [Burnt & Toast Studios](https://burntandtoast.com). Not affiliated with OpenAI or Anthropic.
@@ -0,0 +1,27 @@
1
+ migration_kit/__init__.py,sha256=U9qijUvUFRPgvdGkwjck75q-QM0oETBGjERmkjihLb4,116
2
+ migration_kit/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
3
+ migration_kit/brand.py,sha256=glaklNIcsom1wu4P4LDsJMd_FJaVAFljKsZBMZ0pKjo,1400
4
+ migration_kit/cli.py,sha256=mp3ygiiz20TsTl3ILiFIzcOdER5a5BV4pKfcdrd8PN0,17437
5
+ migration_kit/evaluator.py,sha256=1X-fAsqhlq0la12EQM8PzALbs0SZBxohBdNrlxdHFd8,23767
6
+ migration_kit/initevals.py,sha256=inm4tkQRzCfF6JIL8buU-dXz0vi3MyQQSyKQ-ZBglEE,12757
7
+ migration_kit/licence.py,sha256=YvdrUOrP9GJM1KOvsEDa-6Xo7uH6eKw7TM0wPS5glq4,14864
8
+ migration_kit/packs.py,sha256=cd9QhUyTXlxdogWv-yoPgWVLKjXmR6jJjbaDT6_HE0U,11641
9
+ migration_kit/report.py,sha256=8ZVUnnW3ZQnVTWO33x5lXW58Uv_YLawPLgqq9izSgi0,21751
10
+ migration_kit/scanner.py,sha256=PnPwSgkb9YnsI7Va6jbRb2qt-iu1AmqP5vfDVwdEcRY,16846
11
+ migration_kit/site.py,sha256=ujeU2xvlwvMcCBIDEgJMc7-PqgPnuYAiNJHS8qeq8AA,3025
12
+ migration_kit/swapper.py,sha256=j5ad_fdztKfWOWGaSeJZyLViDjDze-n0rKZU3AZEfPs,4616
13
+ migration_kit/fixer/__init__.py,sha256=fYzn1CHJJ5d7qnvNlL5PxNHpcdlwu8ey-cuWxIQ8e6o,710
14
+ migration_kit/fixer/callsite.py,sha256=xe2UIB5mt10jSyMTKaDWtFbUwMvXUY3Mvl-TAM1NPzo,2040
15
+ migration_kit/fixer/engine.py,sha256=T0aP6W2rjL6h1Pt_6ZV1okh0K1lTSszyXxQXF6B2Bw8,23958
16
+ migration_kit/fixer/jsfix.py,sha256=iu1pvLMJlSH7DQgmCQzIb5lCJy45M1bPZYaoEgio7Qo,14444
17
+ migration_kit/fixer/pyfix.py,sha256=dq2fB5THHVyEK0SNdc72aGgN-t6ANeTVpoBiN1E8UcI,11090
18
+ migration_kit/fixer/verify.py,sha256=_aYYoZiLg0febNduxVghpvYqrH28dRGeOGVVf0UfWGA,14796
19
+ migration_kit/pack_data/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
20
+ migration_kit/pack_data/anthropic.json,sha256=0c_BV0EnIhcUx-8PYKZAwZm1PTZZimj7ftUBr3xAZGs,8491
21
+ migration_kit/pack_data/openai.json,sha256=oLhW80Qg3h-bwry0ZJKteiiB_acUMK8XYoypS76NfMY,43013
22
+ migration_kit/pack_data/pack.schema.json,sha256=AYPeyGGAHE-yYTZROdOfaJ3W8VnhgxobS7ZxxEfh5FE,15465
23
+ lastorders-0.4.0.dist-info/METADATA,sha256=onlkFizEQfN1G_ZO43zjAdlTgZPxV5nsxohs_V-_29w,10747
24
+ lastorders-0.4.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
25
+ lastorders-0.4.0.dist-info/entry_points.txt,sha256=eayw4lQfz_JEGDcxa1Fn3PckPf2LtXOPTRncNCc_Zpo,54
26
+ lastorders-0.4.0.dist-info/top_level.txt,sha256=53GEur-LGzO74ESgMu4tb9PHtiQl3r1eMopbHxMK5Hk,14
27
+ lastorders-0.4.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ lastorders = migration_kit.cli:main
@@ -0,0 +1 @@
1
+ migration_kit
@@ -0,0 +1,2 @@
1
+ """Find and fix retiring AI model IDs (Burnt & Toast Studios). Product name: see brand.py."""
2
+ __version__ = "0.4.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
migration_kit/brand.py ADDED
@@ -0,0 +1,27 @@
1
+ """Product naming in ONE place: everything user-visible reads from here.
2
+
3
+ The product is "Last Orders" (CLI, package and PyPI name `lastorders`, env prefix LASTORDERS_),
4
+ chosen 15 Oct 2026. To rename again, run `python scripts/rename_product.py "New Name" newcli`, which
5
+ updates this file plus every static place Python can't read at build time (pyproject.toml,
6
+ action.yml, workflows, docs, tests, examples, site/ and server/)."""
7
+ import os
8
+
9
+ PRODUCT_NAME = "Last Orders"
10
+ CLI_NAME = "lastorders" # console command, package and PyPI distribution name
11
+ ENV_PREFIX = "LASTORDERS" # env vars: LASTORDERS_OFFLINE, LASTORDERS_LICENCE, ...
12
+ COMPANY = "Burnt & Toast Studios"
13
+ SERVICE_URL = "https://packs.burntandtoast.com" # CLI-facing: licence API, pack files (server/)
14
+ SITE_URL = "https://lastorders.burntandtoast.com" # customer-facing: landing page, buy, terms
15
+ PACKS_URL = f"{SERVICE_URL}/v1"
16
+ LICENCE_URL = f"{SERVICE_URL}/api/licence/verify" # override: $LASTORDERS_LICENCE_URL
17
+ BUY_URL = f"{SITE_URL}/buy" # Stripe Checkout (append ?repo=github.com/owner/name to prefill)
18
+ TERMS_URL = f"{SITE_URL}/terms" # licence terms and refund policy
19
+ CACHE_DIR_NAME = CLI_NAME
20
+
21
+
22
+ def env_name(key: str) -> str:
23
+ return f"{ENV_PREFIX}_{key}"
24
+
25
+
26
+ def env(key: str, default: str = "") -> str:
27
+ return os.environ.get(env_name(key), default)
migration_kit/cli.py ADDED
@@ -0,0 +1,365 @@
1
+ """CLI: scan | swap | fix | init-evals | eval | report | packs | mapping | licence | demo"""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from . import __version__, brand, licence, scanner
10
+ from .packs import load_packs
11
+
12
+ HERE = Path(__file__).resolve().parent.parent
13
+
14
+
15
+ def _packs(a):
16
+ provs = [p.strip() for x in (a.pack or []) for p in x.split(",") if p.strip()] or None
17
+ return load_packs(provs, offline=a.offline or None, url=a.packs_url, extra_files=a.pack_file)
18
+
19
+
20
+ def _off(a):
21
+ """--offline if given, else None so <PREFIX>_OFFLINE decides."""
22
+ return True if getattr(a, "offline", False) else None
23
+
24
+
25
+ def cmd_licence(a) -> int:
26
+ if a.action == "buy":
27
+ print(licence.buy_url(licence.repo_identity(a.repo)))
28
+ print(f"Terms and refunds: {brand.TERMS_URL}", file=sys.stderr)
29
+ return 0
30
+ lic = licence.resolve(a.licence, a.repo)
31
+ if a.action == "forget":
32
+ if lic.key and lic.repo_id:
33
+ licence.clear_cache(lic.key, lic.repo_id)
34
+ print("Cleared the cached licence verification for this repo." if lic.key else "No licence key set.")
35
+ return 0
36
+ lic = licence.require("licence check", a.licence, a.repo, offline=_off(a))
37
+ print(licence.describe(lic))
38
+ return 0
39
+
40
+
41
+ def cmd_scan(a) -> int:
42
+ packs = _packs(a)
43
+ res = scanner.scan(a.repo, include_docs=not a.no_docs, packs=packs)
44
+ j, m = scanner.write_reports(res, a.out)
45
+ s = scanner.summarise(res)
46
+ if a.summary_file:
47
+ Path(a.summary_file).parent.mkdir(parents=True, exist_ok=True)
48
+ with open(a.summary_file, "a", encoding="utf-8") as fh:
49
+ fh.write(scanner.to_summary_markdown(res) + "\n")
50
+ if a.annotations:
51
+ for line in scanner.github_annotations(res):
52
+ print(line)
53
+ if a.format == "json":
54
+ print(json.dumps(res.to_dict()["summary"], indent=2))
55
+ else:
56
+ packs_s = ", ".join(f"{p['provider']} v{p['version']} ({p['source']}"
57
+ + (f"; {p['detail']}" if p["detail"] else "") + ")" for p in res.packs)
58
+ print(f"Scanned {res.files_scanned} files: {s['total_findings']} model references {s['by_risk']}. "
59
+ f"Fine-tunes: {len(s['fine_tuned_models'])}.\n packs: {packs_s}\n {m}\n {j}")
60
+ return 2 if a.fail_on_high and s["by_risk"].get("high") else 0
61
+
62
+
63
+ def cmd_swap(a) -> int:
64
+ from . import swapper
65
+ if a.apply or licence.GATE_SWAP_PATCH:
66
+ print(licence.describe(licence.require("swap-apply", a.licence, a.repo, offline=_off(a))), file=sys.stderr)
67
+ packs = _packs(a)
68
+ mapping = swapper.load_mapping(a.mapping) if a.mapping else swapper.mapping_from_packs(packs)
69
+ if a.include_docs:
70
+ mapping.include_docs = True
71
+ res = swapper.plan(a.repo, mapping, packs)
72
+ p, s = swapper.write(res, a.out)
73
+ print(f"{len(res.changes)} replacements in {len(res.new_contents)} files; "
74
+ f"{len(res.skipped_fine_tunes)} fine-tuned references left alone. Mapping: {mapping.source or a.mapping}")
75
+ print(f" patch: {p}\n summary: {s}")
76
+ if a.apply:
77
+ n = swapper.apply_in_place(a.repo, res)
78
+ print(f"Applied in place to {n} files (--apply).")
79
+ else:
80
+ print(f"Nothing changed on disk. Review, then: git -C {a.repo} apply {Path(p).resolve()}")
81
+ return 0
82
+
83
+
84
+ def _dirty(repo: str, files: list[str]) -> list[str]:
85
+ """Touched files with uncommitted changes (only when the repo is a git checkout)."""
86
+ import shutil
87
+ import subprocess
88
+ git = shutil.which("git")
89
+ if not git or not files:
90
+ return []
91
+ r = subprocess.run([git, "-C", repo, "status", "--porcelain", "--", *files], capture_output=True, text=True)
92
+ return [ln[3:] for ln in r.stdout.splitlines()] if r.returncode == 0 else []
93
+
94
+
95
+ def cmd_fix(a) -> int:
96
+ from . import fixer, swapper
97
+ from .fixer import verify as fverify
98
+ if a.apply or licence.GATE_FIX_PATCH:
99
+ print(licence.describe(licence.require("fix-apply", a.licence, a.repo, offline=_off(a))), file=sys.stderr)
100
+ if a.verify and a.live and not a._demo:
101
+ print(licence.describe(licence.require("eval", a.licence, a.repo, offline=_off(a))), file=sys.stderr)
102
+ packs = _packs(a)
103
+ mapping = swapper.load_mapping(a.mapping) if a.mapping else None
104
+ res = fixer.plan_fixes(a.repo, packs, mapping=mapping, model_swap=not a.no_model_swap,
105
+ assume_model=a.assume_model, include_inferred=a.include_inferred)
106
+ paths = fixer.write(res, a.out)
107
+ c = res.counts()
108
+ print(f"{len(res.new_contents)} files changed ({res.swap_changes} model ID replacements). "
109
+ + ", ".join(f"{k}: {v}" for k, v in sorted(c.items())))
110
+ print(f" patch: {paths['diff']}\n plan: {paths['md']}")
111
+ ok = True
112
+ if a.verify:
113
+ rep = fverify.verify(a.repo, res, packs, Path(a.out) / "verify", live=a.live, suite_path=a.evals,
114
+ assume_model=a.assume_model, include_inferred=a.include_inferred)
115
+ ok = rep["ok"]
116
+ print(f" verify: {'PASS' if ok else 'FAIL'} ({'live' if a.live else 'mock'} eval) -> {Path(a.out) / 'verify' / 'verify.md'}")
117
+ if a.apply:
118
+ if not ok:
119
+ print("Not applied: verification failed (see verify.md).", file=sys.stderr)
120
+ return 6
121
+ dirty = _dirty(a.repo, sorted(res.new_contents))
122
+ if dirty and not a.force:
123
+ print("Not applied: these files have uncommitted changes (commit or stash them, or pass --force): "
124
+ + ", ".join(dirty), file=sys.stderr)
125
+ return 7
126
+ n = fixer.engine.apply_in_place(a.repo, res)
127
+ print(f"Applied in place to {n} files (--apply).")
128
+ else:
129
+ print(f"Nothing changed on disk. Review, then: git -C {a.repo} apply {Path(paths['diff']).resolve()}")
130
+ return 0 if ok else 6
131
+
132
+
133
+ def cmd_init_evals(a) -> int:
134
+ from . import initevals
135
+ if not a._demo:
136
+ print(licence.describe(licence.require("init-evals", a.licence, a.repo, offline=_off(a))), file=sys.stderr)
137
+ packs = _packs(a)
138
+ suite, skipped = initevals.build_suite(a.repo, packs=packs)
139
+ out = initevals.write_suite(suite, skipped, a.out)
140
+ todos = sum(1 for c in suite["cases"] if "todo" in c or "TODO" in str(c.get("prompt", "")))
141
+ print(f"Wrote {out}: {len(suite['cases'])} case(s) from call sites ({todos} need your input). "
142
+ f"Old {suite['models']['old']} -> new {suite['models']['new']}.")
143
+ for s in skipped:
144
+ print(f" not covered: {s}")
145
+ return 0
146
+
147
+
148
+ def cmd_eval(a) -> int:
149
+ from . import evaluator
150
+ if not a._demo:
151
+ print(licence.describe(licence.require("eval", a.licence, ".", offline=_off(a))), file=sys.stderr)
152
+ suite = evaluator.load_suite(a.suite)
153
+ if a.api:
154
+ suite["api"] = a.api
155
+ todo = [c["id"] for c in suite["cases"] if "TODO" in str(c.get("prompt", "")) and not c.get("skip")]
156
+ provider, warn = evaluator.make_provider(suite, a.dry_run)
157
+ if todo and provider.name != "mock":
158
+ print(f"{len(todo)} case(s) still have TODO prompts ({', '.join(todo[:5])}). Replace them with real inputs "
159
+ "(or set `skip: true`) before a live run; nothing was sent.", file=sys.stderr)
160
+ return 5
161
+ if warn:
162
+ print(warn, file=sys.stderr)
163
+ res = evaluator.run_suite(suite, provider, a.old, a.new)
164
+ j, m = evaluator.write(res, a.out)
165
+ print(f"{res['mode']} run of {len(res['cases'])} cases: {res['summary']}\n {m}\n {j}")
166
+ bad = res["summary"].get("fail", 0) + res["summary"].get("error", 0)
167
+ return 3 if a.fail_on_regression and bad else 0
168
+
169
+
170
+ def cmd_report(a) -> int:
171
+ from . import report
172
+ if not a._demo:
173
+ root = json.loads(Path(a.scan).read_text()).get("root", ".")
174
+ print(licence.describe(licence.require("report", a.licence, root if Path(root).exists() else ".", offline=_off(a))), file=sys.stderr)
175
+ md, ht = report.generate(a.client, a.scan, a.out, a.swap_summary, a.diff, a.eval, label=a.label, packs=_packs(a),
176
+ fix_plan_path=a.fix_plan, fix_verify_path=a.fix_verify)
177
+ print(f" {ht}\n {md}")
178
+ return 0
179
+
180
+
181
+ def cmd_packs(a) -> int:
182
+ from . import packs as pk
183
+ if a.action == "list":
184
+ for i in _packs(a).info():
185
+ print(f"{i['provider']:<10} v{i['version']:<12} {i['status']:<7} {i['source']}"
186
+ + (f" ({i['detail']})" if i["detail"] else "") + f" {i['name']}")
187
+ elif a.action == "validate":
188
+ for f in a.files:
189
+ if Path(f).name == "pack.schema.json":
190
+ continue
191
+ pk.validate(json.loads(Path(f).read_text()))
192
+ print(f"OK {f}")
193
+ elif a.action == "build-site":
194
+ from .site import build_site
195
+ out = build_site(Path(a.out))
196
+ print(f"Static site written to {out}")
197
+ return 0
198
+
199
+
200
+ def cmd_mapping(a) -> int:
201
+ from . import swapper
202
+ import yaml
203
+ m = swapper.mapping_from_packs(_packs(a), a.migration or None)
204
+ text = (f"# {m.source}\n" + yaml.safe_dump({"source": m.source, "mappings": m.replacements,
205
+ "fine_tuned": {}, "include_docs": False}, sort_keys=False))
206
+ if a.out:
207
+ Path(a.out).write_text(text)
208
+ print(f"Wrote {a.out}")
209
+ else:
210
+ print(text)
211
+ return 0
212
+
213
+
214
+ def cmd_demo(a) -> int:
215
+ """Run the whole pipeline on examples/ offline (source checkout only; no licence needed)."""
216
+ ex = HERE / "examples"
217
+ if not ex.exists():
218
+ print("demo needs a source checkout (examples/ not found)", file=sys.stderr)
219
+ return 1
220
+ out = Path(a.out)
221
+ repo = ex / "sample-app"
222
+ steps = [["scan", str(repo), "--out", str(out), "--offline"],
223
+ ["swap", str(repo), "--mapping", str(HERE / "mappings" / "gpt4-series-to-gpt-6-luna.yaml"), "--out", str(out), "--offline"],
224
+ ["fix", str(repo), "--out", str(out), "--verify", "--offline"],
225
+ ["eval", str(ex / "evals" / "sample-suite.yaml"), "--dry-run", "--out", str(out)],
226
+ ["report", "--client", "Acme Widgets Ltd (sample)", "--scan", str(out / "scan.json"),
227
+ "--swap-summary", str(out / "swap-summary.json"), "--diff", str(out / "fix.diff"),
228
+ "--fix-plan", str(out / "fix-plan.json"), "--fix-verify", str(out / "verify" / "verify.json"),
229
+ "--eval", str(out / "eval.json"), "--out", str(out), "--offline",
230
+ "--label", "Demo: simulated eval outputs on a fictional sample repo"]]
231
+ for args in steps:
232
+ print(f"$ {brand.CLI_NAME} {' '.join(args)}")
233
+ rc = main(args, _demo=True)
234
+ if rc:
235
+ return rc
236
+ return 0
237
+
238
+
239
+ def _pack_opts(p):
240
+ g = p.add_argument_group("migration packs")
241
+ g.add_argument("--pack", action="append", help="provider pack(s) to use, e.g. openai,anthropic (default: all)")
242
+ g.add_argument("--pack-file", action="append", help="load a local pack JSON (overrides same provider)")
243
+ g.add_argument("--offline", action="store_true",
244
+ help=f"no network at all: use the bundled pack (or ${brand.env_name('OFFLINE')}=1)")
245
+ g.add_argument("--packs-url", help=f"base URL for hosted packs (default: ${brand.env_name('PACKS_URL')} or built-in)")
246
+
247
+
248
+ def _lic_opt(p):
249
+ p.add_argument("--licence", "--license", dest="licence", help=f"licence key (or set ${licence.ENV_VAR})")
250
+
251
+
252
+ def build_parser() -> argparse.ArgumentParser:
253
+ p = argparse.ArgumentParser(prog=brand.CLI_NAME, description=f"{brand.PRODUCT_NAME}: find and fix retiring AI model IDs in your code.")
254
+ p.add_argument("--version", action="version", version=f"{brand.CLI_NAME} {__version__}")
255
+ sub = p.add_subparsers(dest="cmd", required=True)
256
+
257
+ s = sub.add_parser("scan", help="free: find retiring model IDs in a repo")
258
+ s.add_argument("repo", nargs="?", default=".")
259
+ s.add_argument("--out", default=f"{brand.CLI_NAME}-out")
260
+ s.add_argument("--no-docs", action="store_true", help="skip .md/.txt files")
261
+ s.add_argument("--fail-on-high", action="store_true", help="exit 2 if any high-risk finding (for CI)")
262
+ s.add_argument("--summary-file", help="append a compact markdown summary (e.g. $GITHUB_STEP_SUMMARY)")
263
+ s.add_argument("--annotations", action="store_true", help="print GitHub Actions annotations")
264
+ s.add_argument("--format", choices=["text", "json"], default="text", help="stdout format")
265
+ _pack_opts(s)
266
+ s.set_defaults(fn=cmd_scan)
267
+
268
+ w = sub.add_parser("swap", help="write a reviewable patch (--apply needs a licence)")
269
+ w.add_argument("repo")
270
+ w.add_argument("--mapping", help="mapping YAML (default: generated from the packs' headline migrations)")
271
+ w.add_argument("--out", default=f"{brand.CLI_NAME}-out")
272
+ w.add_argument("--include-docs", action="store_true")
273
+ w.add_argument("--apply", action="store_true", help="ALSO edit files in place [licence]")
274
+ _lic_opt(w)
275
+ _pack_opts(w)
276
+ w.set_defaults(fn=cmd_swap)
277
+
278
+ fx = sub.add_parser("fix", help="automatic fixes from the pack rules, as a reviewable patch (--apply needs a licence)")
279
+ fx.add_argument("repo", nargs="?", default=".")
280
+ fx.add_argument("--out", default=f"{brand.CLI_NAME}-out")
281
+ fx.add_argument("--mapping", help="mapping YAML for the model swap (default: generated from the packs)")
282
+ fx.add_argument("--no-model-swap", action="store_true",
283
+ help="only fix arguments; fixes that need the new model are then listed, not applied")
284
+ fx.add_argument("--assume-model", help="model to assume for calls whose model isn't visible in the code")
285
+ fx.add_argument("--include-inferred", action="store_true", help="also apply rules marked 'inferred' in the pack")
286
+ fx.add_argument("--verify", action="store_true",
287
+ help="apply to a scratch copy, check syntax + idempotence, and replay each fixed call old vs new")
288
+ fx.add_argument("--live", action="store_true", help="with --verify: real API calls with YOUR key [licence]")
289
+ fx.add_argument("--evals", help="with --verify: also run your eval suite, with the fixed arguments on matching cases")
290
+ fx.add_argument("--apply", action="store_true", help="ALSO edit files in place [licence]")
291
+ fx.add_argument("--force", action="store_true", help="with --apply: allow files with uncommitted changes")
292
+ _lic_opt(fx)
293
+ _pack_opts(fx)
294
+ fx.set_defaults(fn=cmd_fix)
295
+
296
+ ie = sub.add_parser("init-evals", help="[licence] generate a starter eval suite from the scan's call sites")
297
+ ie.add_argument("repo", nargs="?", default=".")
298
+ ie.add_argument("--out", default="evals/regression.yaml")
299
+ _lic_opt(ie)
300
+ _pack_opts(ie)
301
+ ie.set_defaults(fn=cmd_init_evals)
302
+
303
+ e = sub.add_parser("eval", help="[licence] regression-test old vs new model (uses YOUR provider key)")
304
+ e.add_argument("suite")
305
+ e.add_argument("--old")
306
+ e.add_argument("--new")
307
+ e.add_argument("--api", choices=["chat", "responses"])
308
+ e.add_argument("--dry-run", "--mock", dest="dry_run", action="store_true", help="no API calls; simulated outputs")
309
+ e.add_argument("--out", default=f"{brand.CLI_NAME}-out")
310
+ e.add_argument("--fail-on-regression", action="store_true", help="exit 3 on any fail/error")
311
+ _lic_opt(e)
312
+ e.set_defaults(fn=cmd_eval)
313
+
314
+ r = sub.add_parser("report", help="[licence] client report (HTML + Markdown)")
315
+ r.add_argument("--client", required=True)
316
+ r.add_argument("--scan", required=True)
317
+ r.add_argument("--swap-summary")
318
+ r.add_argument("--diff")
319
+ r.add_argument("--eval")
320
+ r.add_argument("--label", help="banner at the top of the report")
321
+ r.add_argument("--fix-plan", help="fix-plan.json from `fix` (adds the automatic-fixes section)")
322
+ r.add_argument("--fix-verify", help="verify/verify.json from `fix --verify`")
323
+ r.add_argument("--out", default=f"{brand.CLI_NAME}-out")
324
+ _lic_opt(r)
325
+ _pack_opts(r)
326
+ r.set_defaults(fn=cmd_report)
327
+
328
+ k = sub.add_parser("packs", help="list / validate migration packs, or build the static hosting site")
329
+ k.add_argument("action", choices=["list", "validate", "build-site"])
330
+ k.add_argument("files", nargs="*", help="pack files (validate)")
331
+ k.add_argument("--out", default="site", help="output folder (build-site)")
332
+ _pack_opts(k)
333
+ k.set_defaults(fn=cmd_packs)
334
+
335
+ mp = sub.add_parser("mapping", help="print the default swap mapping generated from the packs")
336
+ mp.add_argument("--migration", action="append", help="migration id(s) (default: headline migrations)")
337
+ mp.add_argument("--out")
338
+ _pack_opts(mp)
339
+ mp.set_defaults(fn=cmd_mapping)
340
+
341
+ li = sub.add_parser("licence", aliases=["license"], help="check this repo's licence, print the buy link, or forget the cached check")
342
+ li.add_argument("action", choices=["status", "buy", "forget"])
343
+ li.add_argument("repo", nargs="?", default=".")
344
+ li.add_argument("--offline", action="store_true", help="use only the cached verification")
345
+ _lic_opt(li)
346
+ li.set_defaults(fn=cmd_licence)
347
+
348
+ d = sub.add_parser("demo", help="run everything on examples/ offline (source checkout)")
349
+ d.add_argument("--out", default=str(HERE / "examples" / "demo-output"))
350
+ d.set_defaults(fn=cmd_demo)
351
+ return p
352
+
353
+
354
+ def main(argv: list[str] | None = None, _demo: bool = False) -> int:
355
+ a = build_parser().parse_args(argv)
356
+ a._demo = _demo
357
+ try:
358
+ return a.fn(a)
359
+ except licence.LicenceError as e:
360
+ print(f"{brand.CLI_NAME}: {e}", file=sys.stderr)
361
+ return 4
362
+
363
+
364
+ if __name__ == "__main__":
365
+ raise SystemExit(main())