modelbump 0.1.0__tar.gz → 0.2.0__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 (30) hide show
  1. {modelbump-0.1.0 → modelbump-0.2.0}/PKG-INFO +2 -1
  2. {modelbump-0.1.0 → modelbump-0.2.0}/pyproject.toml +2 -1
  3. {modelbump-0.1.0 → modelbump-0.2.0}/pyproject.toml.orig +2 -1
  4. modelbump-0.2.0/src/modelbump/cli.py +338 -0
  5. modelbump-0.2.0/src/modelbump/runner/__init__.py +30 -0
  6. modelbump-0.2.0/src/modelbump/runner/backends.py +92 -0
  7. modelbump-0.2.0/src/modelbump/runner/budget.py +109 -0
  8. modelbump-0.2.0/src/modelbump/runner/cache.py +136 -0
  9. modelbump-0.2.0/src/modelbump/runner/ollama.py +84 -0
  10. modelbump-0.2.0/src/modelbump/runner/run.py +211 -0
  11. modelbump-0.2.0/src/modelbump/runner/types.py +78 -0
  12. modelbump-0.1.0/src/modelbump/cli.py +0 -154
  13. {modelbump-0.1.0 → modelbump-0.2.0}/LICENSE +0 -0
  14. {modelbump-0.1.0 → modelbump-0.2.0}/README.md +0 -0
  15. {modelbump-0.1.0 → modelbump-0.2.0}/src/modelbump/__init__.py +0 -0
  16. {modelbump-0.1.0 → modelbump-0.2.0}/src/modelbump/py.typed +0 -0
  17. {modelbump-0.1.0 → modelbump-0.2.0}/src/modelbump/registry/__init__.py +0 -0
  18. {modelbump-0.1.0 → modelbump-0.2.0}/src/modelbump/registry/data/anthropic.yaml +0 -0
  19. {modelbump-0.1.0 → modelbump-0.2.0}/src/modelbump/registry/data/google.yaml +0 -0
  20. {modelbump-0.1.0 → modelbump-0.2.0}/src/modelbump/registry/data/openai.yaml +0 -0
  21. {modelbump-0.1.0 → modelbump-0.2.0}/src/modelbump/registry/loader.py +0 -0
  22. {modelbump-0.1.0 → modelbump-0.2.0}/src/modelbump/registry/models.py +0 -0
  23. {modelbump-0.1.0 → modelbump-0.2.0}/src/modelbump/scanner/__init__.py +0 -0
  24. {modelbump-0.1.0 → modelbump-0.2.0}/src/modelbump/scanner/context.py +0 -0
  25. {modelbump-0.1.0 → modelbump-0.2.0}/src/modelbump/scanner/files.py +0 -0
  26. {modelbump-0.1.0 → modelbump-0.2.0}/src/modelbump/scanner/ids.py +0 -0
  27. {modelbump-0.1.0 → modelbump-0.2.0}/src/modelbump/scanner/report.py +0 -0
  28. {modelbump-0.1.0 → modelbump-0.2.0}/src/modelbump/scanner/resolve.py +0 -0
  29. {modelbump-0.1.0 → modelbump-0.2.0}/src/modelbump/scanner/scan.py +0 -0
  30. {modelbump-0.1.0 → modelbump-0.2.0}/src/modelbump/scanner/severity.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: modelbump
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Find the LLM model IDs in your code and config, and warn before providers retire them.
5
5
  Keywords: llm,model deprecation,model retirement,static analysis,github action,openai,anthropic,gemini
6
6
  Author: Hammad Hassan
@@ -15,6 +15,7 @@ Classifier: Programming Language :: Python :: 3.12
15
15
  Classifier: Programming Language :: Python :: 3.13
16
16
  Classifier: Topic :: Software Development :: Quality Assurance
17
17
  Classifier: Typing :: Typed
18
+ Requires-Dist: httpx>=0.28.1
18
19
  Requires-Dist: pydantic>=2.13
19
20
  Requires-Dist: pyyaml>=6.0.3
20
21
  Requires-Dist: tree-sitter>=0.26
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "modelbump"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Find the LLM model IDs in your code and config, and warn before providers retire them."
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -28,6 +28,7 @@ classifiers = [
28
28
  "Typing :: Typed",
29
29
  ]
30
30
  dependencies = [
31
+ "httpx>=0.28.1",
31
32
  "pydantic>=2.13",
32
33
  "pyyaml>=6.0.3",
33
34
  "tree-sitter>=0.26",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "modelbump"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Find the LLM model IDs in your code and config, and warn before providers retire them."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -22,6 +22,7 @@ classifiers = [
22
22
  "Typing :: Typed",
23
23
  ]
24
24
  dependencies = [
25
+ "httpx>=0.28.1",
25
26
  "pydantic>=2.13",
26
27
  "pyyaml>=6.0.3",
27
28
  "tree-sitter>=0.26",
@@ -0,0 +1,338 @@
1
+ """Command-line interface for ModelBump."""
2
+
3
+ import asyncio
4
+ from datetime import date, datetime
5
+ from pathlib import Path
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from modelbump import __version__
11
+ from modelbump.registry import Status, load_registry
12
+ from modelbump.runner import (
13
+ Budget,
14
+ BudgetExceeded,
15
+ ChatRequest,
16
+ ChatResult,
17
+ ResponseCache,
18
+ Runner,
19
+ RunnerError,
20
+ list_local_models,
21
+ pin_versions,
22
+ resolve_model,
23
+ )
24
+ from modelbump.scanner import DEFAULT_SOON_DAYS, scan_path
25
+ from modelbump.scanner.report import (
26
+ RENDERERS,
27
+ OutputFormat,
28
+ build_report,
29
+ failure_line,
30
+ summary_line,
31
+ )
32
+
33
+ app = typer.Typer(
34
+ name="modelbump",
35
+ help="Dependabot for LLM models.",
36
+ no_args_is_help=True,
37
+ )
38
+
39
+
40
+ def _version_callback(value: bool) -> None:
41
+ if value:
42
+ typer.echo(f"modelbump {__version__}")
43
+ raise typer.Exit()
44
+
45
+
46
+ @app.callback()
47
+ def main(
48
+ version: Annotated[
49
+ bool,
50
+ typer.Option(
51
+ "--version",
52
+ "-V",
53
+ help="Show the version and exit.",
54
+ callback=_version_callback,
55
+ is_eager=True,
56
+ ),
57
+ ] = False,
58
+ ) -> None:
59
+ """Dependabot for LLM models."""
60
+
61
+
62
+ @app.command()
63
+ def scan(
64
+ path: Annotated[
65
+ Path,
66
+ typer.Argument(help="Folder or file to scan.", exists=True, resolve_path=True),
67
+ ] = Path("."),
68
+ soon_days: Annotated[
69
+ int,
70
+ typer.Option(
71
+ min=0, help="Mark a model RETIRING_SOON when it shuts down within this many days."
72
+ ),
73
+ ] = DEFAULT_SOON_DAYS,
74
+ fail_within: Annotated[
75
+ int | None,
76
+ typer.Option(
77
+ min=0,
78
+ help="Exit with code 1 if a model is retired or shuts down within this many days.",
79
+ ),
80
+ ] = None,
81
+ today: Annotated[
82
+ datetime | None,
83
+ typer.Option(
84
+ formats=["%Y-%m-%d"],
85
+ help="Check as if today were this day (YYYY-MM-DD), to replay the past.",
86
+ ),
87
+ ] = None,
88
+ output_format: Annotated[
89
+ OutputFormat,
90
+ typer.Option("--format", help="Report format: text, json or sarif."),
91
+ ] = OutputFormat.TEXT,
92
+ exclude: Annotated[
93
+ list[str] | None,
94
+ typer.Option(
95
+ help="Skip paths matching this pattern, e.g. 'tests' or '*.ipynb'. Repeat for more.",
96
+ ),
97
+ ] = None,
98
+ skip_comments: Annotated[
99
+ bool,
100
+ typer.Option(
101
+ help="Leave out IDs that only describe a model: comments, docstrings, "
102
+ "sentences and price tables.",
103
+ ),
104
+ ] = False,
105
+ skip_tests: Annotated[
106
+ bool,
107
+ typer.Option(help="Leave out test files and test folders."),
108
+ ] = False,
109
+ output: Annotated[
110
+ Path | None,
111
+ typer.Option(help="Write the report to this file instead of the screen.", dir_okay=False),
112
+ ] = None,
113
+ ) -> None:
114
+ """Find the LLM model IDs in a repository's code and config, and how urgent each is."""
115
+ registry = load_registry()
116
+ day = today.date() if today is not None else date.today()
117
+ result = scan_path(path, registry, exclude or (), skip_comments, skip_tests)
118
+ report = build_report(result, registry, day, soon_days, fail_within, _root_uri(path))
119
+ rendered = RENDERERS[output_format](report)
120
+ if output is None:
121
+ typer.echo(rendered, nl=False)
122
+ else:
123
+ output.write_text(rendered, encoding="utf-8")
124
+ typer.echo(f"Wrote {output_format.value} report to {output}.")
125
+ typer.echo(summary_line(report))
126
+ failure = failure_line(report)
127
+ if failure is not None:
128
+ if output is not None or output_format is not OutputFormat.TEXT:
129
+ # Keep the screen output a valid JSON or SARIF document.
130
+ typer.echo(failure, err=True)
131
+ raise typer.Exit(code=1)
132
+
133
+
134
+ def _root_uri(path: Path) -> str:
135
+ """The scan root relative to the working directory, for SARIF paths."""
136
+ base = path.parent if path.is_file() else path
137
+ try:
138
+ relative = base.relative_to(Path.cwd()).as_posix()
139
+ except ValueError:
140
+ return ""
141
+ return "" if relative == "." else relative + "/"
142
+
143
+
144
+ @app.command()
145
+ def check(
146
+ model_id: Annotated[str, typer.Argument(help="Model ID, e.g. claude-3-haiku-20240307.")],
147
+ ) -> None:
148
+ """Look up one model ID in the retirement registry."""
149
+ found = load_registry().get(model_id)
150
+ if found is None:
151
+ typer.echo(f"{model_id}: no retirement announced (not in the registry).")
152
+ return
153
+ provider, model = found
154
+ status = model.status_on(date.today())
155
+ if status is Status.RETIRED:
156
+ line = f"RETIRED since {model.shutdown}"
157
+ if model.shutdown_is_earliest:
158
+ line += " (the earliest possible date the provider gave)"
159
+ elif model.shutdown_is_earliest:
160
+ line = f"{status.value.upper()}, shuts down no earlier than {model.shutdown}"
161
+ else:
162
+ line = f"{status.value.upper()}, shuts down on {model.shutdown}"
163
+ typer.echo(f"{model_id} ({provider.value}): {line}.")
164
+ if model.superseded:
165
+ typer.echo(f"Rescheduled: {len(model.superseded)} earlier plan(s) were replaced.")
166
+ if model.replacement:
167
+ typer.echo(f"Suggested replacement: {model.replacement}")
168
+
169
+
170
+ @app.command()
171
+ def ask(
172
+ prompts: Annotated[
173
+ list[str], typer.Argument(help="What to ask the model. Give several to ask them all.")
174
+ ],
175
+ model: Annotated[
176
+ str,
177
+ typer.Option("--model", "-m", help="endpoint/model, e.g. ollama/qwen2.5:7b."),
178
+ ] = "ollama/qwen2.5:7b",
179
+ system: Annotated[str | None, typer.Option(help="A system message.")] = None,
180
+ temperature: Annotated[float, typer.Option(min=0.0, help="Sampling temperature.")] = 0.0,
181
+ max_tokens: Annotated[
182
+ int | None, typer.Option(min=1, help="Longest answer, in tokens.")
183
+ ] = None,
184
+ seed: Annotated[
185
+ int | None, typer.Option(help="Sampling seed, if the model supports it.")
186
+ ] = None,
187
+ sample: Annotated[
188
+ int, typer.Option(min=0, help="Which repeated draw this is (0, 1, 2, ...).")
189
+ ] = 0,
190
+ timeout: Annotated[float, typer.Option(min=1.0, help="Seconds each attempt may take.")] = 120.0,
191
+ attempts: Annotated[int, typer.Option(min=1, help="Tries before giving up.")] = 4,
192
+ use_cache: Annotated[
193
+ bool,
194
+ typer.Option("--cache/--no-cache", help="Reuse a stored answer to the same request."),
195
+ ] = True,
196
+ budget_tokens: Annotated[
197
+ int | None,
198
+ typer.Option(min=1, help="Stop sending requests after this many tokens (in + out)."),
199
+ ] = None,
200
+ budget_requests: Annotated[
201
+ int | None,
202
+ typer.Option(min=1, help="Stop after this many requests to the model, retries included."),
203
+ ] = None,
204
+ think: Annotated[
205
+ str | None,
206
+ typer.Option(
207
+ help="Reasoning effort for thinking models: none, low, medium or high. "
208
+ "Leave it out for the model's default."
209
+ ),
210
+ ] = None,
211
+ ) -> None:
212
+ """Send prompts to a model and print the answers (to try the runner)."""
213
+ requests = [
214
+ ChatRequest.ask(
215
+ model,
216
+ prompt,
217
+ system,
218
+ temperature=temperature,
219
+ max_tokens=max_tokens,
220
+ seed=seed,
221
+ sample=sample,
222
+ reasoning_effort=think,
223
+ )
224
+ for prompt in prompts
225
+ ]
226
+ try:
227
+ endpoint, _ = resolve_model(model)
228
+ requests = pin_versions(requests)
229
+ except ValueError as error:
230
+ typer.echo(f"Error: {error}", err=True)
231
+ raise typer.Exit(code=1) from error
232
+ except RunnerError as error:
233
+ _print_error(error, model)
234
+ raise typer.Exit(code=1) from error
235
+ if not endpoint.local:
236
+ typer.echo(
237
+ f"Note: {endpoint.name} is a free cloud tier. Your prompts leave this machine "
238
+ "and may be kept or read by the provider. Never send private data.",
239
+ err=True,
240
+ )
241
+ cache = ResponseCache() if use_cache else None
242
+ budget = Budget(max_tokens=budget_tokens, max_requests=budget_requests)
243
+ runner = Runner(timeout=timeout, max_attempts=attempts, cache=cache, budget=budget)
244
+ try:
245
+ outcomes = asyncio.run(runner.run_each(requests))
246
+ finally:
247
+ if cache is not None:
248
+ cache.close()
249
+ failed = False
250
+ for prompt, outcome in zip(prompts, outcomes, strict=True):
251
+ if len(prompts) > 1:
252
+ typer.echo(f"## {prompt}")
253
+ if isinstance(outcome, RunnerError):
254
+ failed = True
255
+ _print_error(outcome, model)
256
+ continue
257
+ typer.echo(outcome.text)
258
+ typer.echo(_result_line(outcome), err=True)
259
+ if len(prompts) > 1 or budget_tokens is not None or budget_requests is not None:
260
+ typer.echo(budget.summary(), err=True)
261
+ if any(isinstance(o, BudgetExceeded) for o in outcomes):
262
+ typer.echo(
263
+ "Answers so far are cached: run the same command with a higher limit to continue.",
264
+ err=True,
265
+ )
266
+ if failed:
267
+ raise typer.Exit(code=1)
268
+
269
+
270
+ def _print_error(error: RunnerError, model: str) -> None:
271
+ typer.echo(f"Error: {error}", err=True)
272
+ if model.startswith("ollama/") and "could not reach" in str(error):
273
+ typer.echo("Is Ollama running? Start it with: open -a Ollama", err=True)
274
+
275
+
276
+ @app.command()
277
+ def models() -> None:
278
+ """List the models pulled into Ollama, with the digest that pins each one."""
279
+ try:
280
+ found = list_local_models()
281
+ except RunnerError as error:
282
+ _print_error(error, "ollama/")
283
+ raise typer.Exit(code=1) from error
284
+ if not found:
285
+ typer.echo("No models yet. Pull one with: ollama pull qwen2.5:7b")
286
+ return
287
+ width = max(len(m.name) for m in found)
288
+ typer.echo(f"{'MODEL':<{width + 7}} PARAMS QUANT SIZE DIGEST")
289
+ for m in found:
290
+ size = f"{m.size_bytes / 1e9:.1f} GB"
291
+ typer.echo(
292
+ f"{'ollama/' + m.name:<{width + 7}} {m.parameter_size:<6} {m.quantization:<7} "
293
+ f"{size:<7} {m.digest[:12]}"
294
+ )
295
+
296
+
297
+ def _result_line(result: ChatResult) -> str:
298
+ if result.cached:
299
+ how = "from cache"
300
+ else:
301
+ tries = "attempt" if result.attempts == 1 else "attempts"
302
+ how = f"{result.attempts} {tries}, {result.seconds:.1f}s"
303
+ return (
304
+ f"[{result.model}: {result.input_tokens} tokens in, {result.output_tokens} out, "
305
+ f"{how}, finish: {result.finish_reason}]"
306
+ )
307
+
308
+
309
+ cache_app = typer.Typer(help="Look at or empty the cache of model answers.", no_args_is_help=True)
310
+ app.add_typer(cache_app, name="cache")
311
+
312
+
313
+ @cache_app.command("info")
314
+ def cache_info() -> None:
315
+ """Show where the cache is and how much it holds."""
316
+ cache = ResponseCache()
317
+ try:
318
+ stats = cache.stats()
319
+ finally:
320
+ cache.close()
321
+ typer.echo(f"Cache: {stats.path}")
322
+ typer.echo(f"Answers stored: {stats.entries} ({stats.output_tokens} output tokens)")
323
+ typer.echo(f"Size on disk: {stats.size_bytes / 1024:.1f} KiB")
324
+
325
+
326
+ @cache_app.command("clear")
327
+ def cache_clear(
328
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Do not ask for confirmation.")] = False,
329
+ ) -> None:
330
+ """Delete every stored answer."""
331
+ cache = ResponseCache()
332
+ try:
333
+ if not yes:
334
+ typer.confirm(f"Delete every answer stored in {cache.path}?", abort=True)
335
+ deleted = cache.clear()
336
+ finally:
337
+ cache.close()
338
+ typer.echo(f"Deleted {deleted} stored answers.")
@@ -0,0 +1,30 @@
1
+ """Runner: call LLMs with retries, a cache and a budget (ADRs 0036-0038)."""
2
+
3
+ from modelbump.runner.backends import ENDPOINTS, Endpoint, resolve_model
4
+ from modelbump.runner.budget import Budget, BudgetExceeded, Spend
5
+ from modelbump.runner.cache import CacheStats, ResponseCache, cache_key, default_cache_path
6
+ from modelbump.runner.ollama import LocalModel, find_model, list_local_models, pin_versions
7
+ from modelbump.runner.run import Runner
8
+ from modelbump.runner.types import ChatRequest, ChatResult, Message, RunnerError
9
+
10
+ __all__ = [
11
+ "ENDPOINTS",
12
+ "Budget",
13
+ "BudgetExceeded",
14
+ "CacheStats",
15
+ "ChatRequest",
16
+ "ChatResult",
17
+ "Endpoint",
18
+ "LocalModel",
19
+ "Message",
20
+ "ResponseCache",
21
+ "Runner",
22
+ "RunnerError",
23
+ "Spend",
24
+ "cache_key",
25
+ "default_cache_path",
26
+ "find_model",
27
+ "list_local_models",
28
+ "pin_versions",
29
+ "resolve_model",
30
+ ]
@@ -0,0 +1,92 @@
1
+ """Where each model is served.
2
+
3
+ Every endpoint speaks the OpenAI-compatible chat completions API, so one
4
+ small HTTP client covers all of them (ADR 0036). Only free endpoints are
5
+ listed: local models through Ollama, and free cloud tiers (ADR 0040).
6
+ """
7
+
8
+ import os
9
+ from dataclasses import dataclass
10
+ from typing import Any
11
+
12
+ from modelbump.runner.types import ChatRequest, RunnerError
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Endpoint:
17
+ """An OpenAI-compatible server.
18
+
19
+ - `api_key_env`: the environment variable that holds the key, if one is needed.
20
+ - `rpm`: requests per minute to stay under; the runner spaces requests to
21
+ this endpoint so a free tier's per-minute limit is never hit.
22
+ - `local`: True when the model runs on this machine. Prompts sent to a
23
+ cloud endpoint leave the machine, and free tiers may keep and read them.
24
+ """
25
+
26
+ name: str
27
+ base_url: str
28
+ api_key_env: str | None = None
29
+ rpm: float | None = None
30
+ local: bool = False
31
+
32
+ def headers(self) -> dict[str, str]:
33
+ """HTTP headers, with the API key read from the environment if one is needed."""
34
+ if self.api_key_env is None:
35
+ return {}
36
+ key = os.environ.get(self.api_key_env)
37
+ if not key:
38
+ raise RunnerError(f"Set {self.api_key_env} to use the {self.name} endpoint.")
39
+ return {"Authorization": f"Bearer {key}"}
40
+
41
+
42
+ # Ollama listens on port 11434 of this machine unless MODELBUMP_OLLAMA_URL says otherwise.
43
+ OLLAMA_URL = os.environ.get("MODELBUMP_OLLAMA_URL", "http://localhost:11434").rstrip("/")
44
+
45
+ # Free tiers only. Limits change often: the rpm values sit at or below the
46
+ # free limits checked on 2026-10-27 (ADR 0040). Their keys are free and need no card.
47
+ ENDPOINTS: dict[str, Endpoint] = {
48
+ "ollama": Endpoint("ollama", f"{OLLAMA_URL}/v1", local=True),
49
+ "groq": Endpoint("groq", "https://api.groq.com/openai/v1", "GROQ_API_KEY", rpm=30),
50
+ "gemini": Endpoint(
51
+ "gemini",
52
+ "https://generativelanguage.googleapis.com/v1beta/openai",
53
+ "GEMINI_API_KEY",
54
+ rpm=10,
55
+ ),
56
+ "openrouter": Endpoint(
57
+ "openrouter", "https://openrouter.ai/api/v1", "OPENROUTER_API_KEY", rpm=20
58
+ ),
59
+ }
60
+
61
+
62
+ def resolve_model(model: str) -> tuple[Endpoint, str]:
63
+ """Split `endpoint/model` into the endpoint and the model name it expects.
64
+
65
+ Only the first `/` splits, so model names may contain `/` themselves.
66
+ """
67
+ name, sep, rest = model.partition("/")
68
+ if not sep or not rest:
69
+ raise ValueError(
70
+ f"Write the model as endpoint/model, e.g. ollama/qwen2.5:7b (got {model!r})."
71
+ )
72
+ if name not in ENDPOINTS:
73
+ known = ", ".join(sorted(ENDPOINTS))
74
+ raise ValueError(f"Unknown endpoint {name!r} in {model!r}. Known: {known}.")
75
+ return ENDPOINTS[name], rest
76
+
77
+
78
+ def request_body(request: ChatRequest, model_name: str) -> dict[str, Any]:
79
+ """The JSON body for POST /chat/completions."""
80
+ body: dict[str, Any] = {
81
+ "model": model_name,
82
+ "messages": [{"role": m.role, "content": m.content} for m in request.messages],
83
+ "temperature": request.temperature,
84
+ "stream": False,
85
+ }
86
+ if request.max_tokens is not None:
87
+ body["max_tokens"] = request.max_tokens
88
+ if request.seed is not None:
89
+ body["seed"] = request.seed
90
+ if request.reasoning_effort is not None:
91
+ body["reasoning_effort"] = request.reasoning_effort
92
+ return body
@@ -0,0 +1,109 @@
1
+ """A spending limit for model calls, counted in tokens and requests (ADR 0038).
2
+
3
+ ModelBump spends no money: local models cost time and free API tiers allow
4
+ a fixed number of requests and tokens a day. So the budget counts tokens and
5
+ HTTP requests, not dollars. Answers read from the cache cost nothing and are
6
+ counted separately, as savings.
7
+ """
8
+
9
+ from dataclasses import dataclass
10
+
11
+ from modelbump.runner.types import ChatResult, RunnerError
12
+
13
+
14
+ class BudgetExceeded(RunnerError):
15
+ """The budget ran out before this request could be sent."""
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Spend:
20
+ """What a run used so far."""
21
+
22
+ requests: int
23
+ answers: int
24
+ input_tokens: int
25
+ output_tokens: int
26
+ cached_answers: int
27
+ cached_tokens: int
28
+
29
+ @property
30
+ def tokens(self) -> int:
31
+ return self.input_tokens + self.output_tokens
32
+
33
+
34
+ class Budget:
35
+ """Stops sending requests once a limit is reached.
36
+
37
+ - `max_tokens`: input plus output tokens of real (not cached) answers.
38
+ - `max_requests`: HTTP requests sent, retries included, because free
39
+ tiers count every request.
40
+
41
+ A limit is checked before each request is sent. Requests already in
42
+ flight are allowed to finish, so the token total can end slightly above
43
+ `max_tokens` (by at most what the in-flight answers use).
44
+ """
45
+
46
+ def __init__(self, max_tokens: int | None = None, max_requests: int | None = None) -> None:
47
+ if max_tokens is not None and max_tokens < 1:
48
+ raise ValueError("max_tokens must be at least 1.")
49
+ if max_requests is not None and max_requests < 1:
50
+ raise ValueError("max_requests must be at least 1.")
51
+ self.max_tokens = max_tokens
52
+ self.max_requests = max_requests
53
+ self._requests = 0
54
+ self._answers = 0
55
+ self._input = 0
56
+ self._output = 0
57
+ self._cached_answers = 0
58
+ self._cached_tokens = 0
59
+
60
+ def before_request(self) -> None:
61
+ """Count one request about to be sent, or raise BudgetExceeded."""
62
+ if self.max_requests is not None and self._requests >= self.max_requests:
63
+ raise BudgetExceeded(
64
+ f"Budget reached: {self._requests:,} of {self.max_requests:,} requests sent."
65
+ )
66
+ if self.max_tokens is not None and self._input + self._output >= self.max_tokens:
67
+ raise BudgetExceeded(
68
+ f"Budget reached: {self._input + self._output:,} of "
69
+ f"{self.max_tokens:,} tokens used."
70
+ )
71
+ self._requests += 1
72
+
73
+ def record(self, result: ChatResult) -> None:
74
+ """Count an answer: real ones use the budget, cached ones are savings."""
75
+ if result.cached:
76
+ self._cached_answers += 1
77
+ self._cached_tokens += result.input_tokens + result.output_tokens
78
+ return
79
+ self._answers += 1
80
+ self._input += result.input_tokens
81
+ self._output += result.output_tokens
82
+
83
+ @property
84
+ def spend(self) -> Spend:
85
+ return Spend(
86
+ requests=self._requests,
87
+ answers=self._answers,
88
+ input_tokens=self._input,
89
+ output_tokens=self._output,
90
+ cached_answers=self._cached_answers,
91
+ cached_tokens=self._cached_tokens,
92
+ )
93
+
94
+ def summary(self) -> str:
95
+ """One line for people, e.g. at the end of a run."""
96
+ s = self.spend
97
+ line = (
98
+ f"Spent: {s.requests:,} requests, {s.tokens:,} tokens "
99
+ f"({s.input_tokens:,} in, {s.output_tokens:,} out). "
100
+ f"From cache: {s.cached_answers:,} answers ({s.cached_tokens:,} tokens saved)."
101
+ )
102
+ limits = []
103
+ if self.max_tokens is not None:
104
+ limits.append(f"{s.tokens / self.max_tokens:.0%} of {self.max_tokens:,} tokens")
105
+ if self.max_requests is not None:
106
+ limits.append(f"{s.requests / self.max_requests:.0%} of {self.max_requests:,} requests")
107
+ if limits:
108
+ line += " Budget used: " + ", ".join(limits) + "."
109
+ return line
@@ -0,0 +1,136 @@
1
+ """A content-addressed cache of model answers, in one SQLite file (ADR 0037).
2
+
3
+ The key is a SHA-256 hash of everything that can change an answer: the model,
4
+ the full message list, the sampling options and the sample number. Asking the
5
+ same thing twice reads the first answer back instead of calling the model, so
6
+ experiments can be re-run for free and give the same numbers.
7
+ """
8
+
9
+ import hashlib
10
+ import json
11
+ import os
12
+ import sqlite3
13
+ from dataclasses import dataclass
14
+ from datetime import UTC, datetime
15
+ from pathlib import Path
16
+
17
+ from modelbump.runner.types import ChatRequest, ChatResult
18
+
19
+ # Bump this when the key recipe changes, so old entries are never misread.
20
+ # 2: adds reasoning_effort and model_version (ADR 0039).
21
+ KEY_VERSION = 2
22
+
23
+ _SCHEMA = """
24
+ CREATE TABLE IF NOT EXISTS responses (
25
+ key TEXT PRIMARY KEY,
26
+ model TEXT NOT NULL,
27
+ text TEXT NOT NULL,
28
+ input_tokens INTEGER NOT NULL,
29
+ output_tokens INTEGER NOT NULL,
30
+ finish_reason TEXT NOT NULL,
31
+ created_at TEXT NOT NULL
32
+ )
33
+ """
34
+
35
+
36
+ def default_cache_path() -> Path:
37
+ """`$MODELBUMP_CACHE_DIR/responses.sqlite`, else the user's cache folder."""
38
+ folder = os.environ.get("MODELBUMP_CACHE_DIR")
39
+ if folder:
40
+ return Path(folder) / "responses.sqlite"
41
+ base = os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache"
42
+ return Path(base) / "modelbump" / "responses.sqlite"
43
+
44
+
45
+ def cache_key(request: ChatRequest) -> str:
46
+ """The SHA-256 hex digest of the request's canonical JSON form."""
47
+ payload = {
48
+ "v": KEY_VERSION,
49
+ "model": request.model,
50
+ "messages": [[m.role, m.content] for m in request.messages],
51
+ "temperature": request.temperature,
52
+ "max_tokens": request.max_tokens,
53
+ "seed": request.seed,
54
+ "sample": request.sample,
55
+ "reasoning_effort": request.reasoning_effort,
56
+ "model_version": request.model_version,
57
+ }
58
+ canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
59
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class CacheStats:
64
+ """What the cache holds."""
65
+
66
+ path: Path
67
+ entries: int
68
+ output_tokens: int
69
+ size_bytes: int
70
+
71
+
72
+ class ResponseCache:
73
+ """Stores successful answers by request hash. Errors are never cached."""
74
+
75
+ def __init__(self, path: Path | None = None) -> None:
76
+ self.path = path if path is not None else default_cache_path()
77
+ self.path.parent.mkdir(parents=True, exist_ok=True)
78
+ self._db = sqlite3.connect(self.path)
79
+ # WAL lets two processes read and write the same cache safely.
80
+ self._db.execute("PRAGMA journal_mode=WAL")
81
+ self._db.execute(_SCHEMA)
82
+ self._db.commit()
83
+
84
+ def get(self, request: ChatRequest) -> ChatResult | None:
85
+ """The stored answer for this request, or None."""
86
+ row = self._db.execute(
87
+ "SELECT text, input_tokens, output_tokens, finish_reason FROM responses WHERE key = ?",
88
+ (cache_key(request),),
89
+ ).fetchone()
90
+ if row is None:
91
+ return None
92
+ text, input_tokens, output_tokens, finish_reason = row
93
+ return ChatResult(
94
+ text=text,
95
+ model=request.model,
96
+ input_tokens=input_tokens,
97
+ output_tokens=output_tokens,
98
+ finish_reason=finish_reason,
99
+ attempts=0,
100
+ seconds=0.0,
101
+ cached=True,
102
+ )
103
+
104
+ def put(self, request: ChatRequest, result: ChatResult) -> None:
105
+ """Store an answer. A second answer for the same request replaces the first."""
106
+ self._db.execute(
107
+ "INSERT OR REPLACE INTO responses VALUES (?, ?, ?, ?, ?, ?, ?)",
108
+ (
109
+ cache_key(request),
110
+ request.model,
111
+ result.text,
112
+ result.input_tokens,
113
+ result.output_tokens,
114
+ result.finish_reason,
115
+ datetime.now(UTC).isoformat(timespec="seconds"),
116
+ ),
117
+ )
118
+ self._db.commit()
119
+
120
+ def stats(self) -> CacheStats:
121
+ """How many answers are stored, and how big the file is."""
122
+ entries, tokens = self._db.execute(
123
+ "SELECT COUNT(*), COALESCE(SUM(output_tokens), 0) FROM responses"
124
+ ).fetchone()
125
+ size = sum(p.stat().st_size for p in (self.path, Path(f"{self.path}-wal")) if p.exists())
126
+ return CacheStats(self.path, entries, tokens, size)
127
+
128
+ def clear(self) -> int:
129
+ """Delete every stored answer. Returns how many were deleted."""
130
+ deleted = self._db.execute("DELETE FROM responses").rowcount
131
+ self._db.commit()
132
+ self._db.execute("VACUUM")
133
+ return deleted
134
+
135
+ def close(self) -> None:
136
+ self._db.close()
@@ -0,0 +1,84 @@
1
+ """Ask the local Ollama server which models it has, and their exact versions (ADR 0039)."""
2
+
3
+ from dataclasses import dataclass, replace
4
+
5
+ import httpx
6
+
7
+ from modelbump.runner.backends import OLLAMA_URL
8
+ from modelbump.runner.types import ChatRequest, RunnerError
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class LocalModel:
13
+ """A model pulled into Ollama."""
14
+
15
+ name: str
16
+ digest: str
17
+ size_bytes: int
18
+ family: str
19
+ parameter_size: str
20
+ quantization: str
21
+
22
+
23
+ def list_local_models(
24
+ base_url: str = OLLAMA_URL, transport: httpx.BaseTransport | None = None
25
+ ) -> list[LocalModel]:
26
+ """Every model Ollama has, sorted by name (GET /api/tags)."""
27
+ try:
28
+ with httpx.Client(timeout=10.0, transport=transport) as client:
29
+ response = client.get(f"{base_url}/api/tags")
30
+ except httpx.HTTPError as error:
31
+ raise RunnerError(f"could not reach {base_url} ({type(error).__name__})") from error
32
+ if response.status_code != 200:
33
+ raise RunnerError(f"{base_url}/api/tags answered HTTP {response.status_code}")
34
+ models = []
35
+ for item in response.json().get("models") or []:
36
+ details = item.get("details") or {}
37
+ models.append(
38
+ LocalModel(
39
+ name=str(item.get("name") or item.get("model") or ""),
40
+ digest=str(item.get("digest") or ""),
41
+ size_bytes=int(item.get("size") or 0),
42
+ family=str(details.get("family") or ""),
43
+ parameter_size=str(details.get("parameter_size") or ""),
44
+ quantization=str(details.get("quantization_level") or ""),
45
+ )
46
+ )
47
+ return sorted(models, key=lambda m: m.name)
48
+
49
+
50
+ def find_model(name: str, models: list[LocalModel]) -> LocalModel | None:
51
+ """The local model called `name`. A name without a tag means `:latest`, as in Ollama."""
52
+ wanted = name if ":" in name else f"{name}:latest"
53
+ for model in models:
54
+ if model.name == wanted:
55
+ return model
56
+ return None
57
+
58
+
59
+ def pin_versions(
60
+ requests: list[ChatRequest],
61
+ base_url: str = OLLAMA_URL,
62
+ transport: httpx.BaseTransport | None = None,
63
+ ) -> list[ChatRequest]:
64
+ """Fill in `model_version` with the Ollama digest of every `ollama/...` request.
65
+
66
+ Then a model re-pulled with new weights under the same name gets new cache
67
+ entries instead of old answers. A model that is not pulled is an error that
68
+ says how to pull it. Other endpoints are left as they are.
69
+ """
70
+ if not any(r.model.startswith("ollama/") for r in requests):
71
+ return list(requests)
72
+ models = list_local_models(base_url, transport)
73
+ pinned = []
74
+ for request in requests:
75
+ if request.model.startswith("ollama/"):
76
+ name = request.model.removeprefix("ollama/")
77
+ found = find_model(name, models)
78
+ if found is None:
79
+ raise RunnerError(
80
+ f"{request.model}: this model is not pulled. Run: ollama pull {name}"
81
+ )
82
+ request = replace(request, model_version=found.digest)
83
+ pinned.append(request)
84
+ return pinned
@@ -0,0 +1,211 @@
1
+ """Send chat requests with a timeout, retries with backoff, and a concurrency limit."""
2
+
3
+ import asyncio
4
+ import random
5
+ import re
6
+ import time
7
+ from collections.abc import Awaitable, Callable, Sequence
8
+ from typing import Any
9
+
10
+ import httpx
11
+
12
+ from modelbump.runner.backends import Endpoint, request_body, resolve_model
13
+ from modelbump.runner.budget import Budget
14
+ from modelbump.runner.cache import ResponseCache
15
+ from modelbump.runner.types import ChatRequest, ChatResult, RunnerError
16
+
17
+ # Statuses worth another try: rate limits and server-side failures.
18
+ RETRY_STATUSES = frozenset({408, 429, 500, 502, 503, 504})
19
+
20
+ Sleep = Callable[[float], Awaitable[None]]
21
+ Clock = Callable[[], float]
22
+
23
+ # Some thinking models put their reasoning at the start of the answer.
24
+ _THINK = re.compile(r"^\s*<think>.*?</think>\s*", re.DOTALL)
25
+
26
+
27
+ class Runner:
28
+ """Calls models. One Runner can be reused for many requests.
29
+
30
+ - `timeout`: seconds each attempt may take.
31
+ - `max_attempts`: tries per request, counting the first one.
32
+ - `base_delay`, `max_delay`: exponential backoff with full jitter, in seconds.
33
+ A `Retry-After` header from the server wins when it asks for longer.
34
+ - `concurrency`: requests in flight at once in `run_many`.
35
+ - `cache`: answers are read from it first and written to it after a
36
+ successful call (ADR 0037). None means every request calls the model.
37
+ - `budget`: counts tokens and requests, and stops sending requests once
38
+ a limit is reached (ADR 0038). None means no limit.
39
+
40
+ Requests to an endpoint with an `rpm` are spaced at least 60/rpm seconds
41
+ apart, retries included (ADR 0040).
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ *,
47
+ timeout: float = 120.0,
48
+ max_attempts: int = 4,
49
+ base_delay: float = 1.0,
50
+ max_delay: float = 30.0,
51
+ concurrency: int = 2,
52
+ cache: ResponseCache | None = None,
53
+ budget: Budget | None = None,
54
+ transport: httpx.AsyncBaseTransport | None = None,
55
+ sleep: Sleep = asyncio.sleep,
56
+ clock: Clock = time.monotonic,
57
+ rng: random.Random | None = None,
58
+ ) -> None:
59
+ if max_attempts < 1:
60
+ raise ValueError("max_attempts must be at least 1.")
61
+ if concurrency < 1:
62
+ raise ValueError("concurrency must be at least 1.")
63
+ self.timeout = timeout
64
+ self.max_attempts = max_attempts
65
+ self.base_delay = base_delay
66
+ self.max_delay = max_delay
67
+ self.concurrency = concurrency
68
+ self.cache = cache
69
+ self.budget = budget
70
+ self._transport = transport
71
+ self._sleep = sleep
72
+ self._clock = clock
73
+ self._next_slot: dict[str, float] = {}
74
+ self._rng = rng or random.Random()
75
+
76
+ async def run(self, request: ChatRequest) -> ChatResult:
77
+ """Send one request."""
78
+ async with self._client() as client:
79
+ return await self._run(client, request)
80
+
81
+ async def run_many(self, requests: Sequence[ChatRequest]) -> list[ChatResult]:
82
+ """Send many requests, at most `concurrency` at a time. Results keep the input order.
83
+
84
+ Every request is tried, so all answers that succeed are cached. Then
85
+ the first error, if any, is raised.
86
+ """
87
+ outcomes = await self.run_each(requests)
88
+ for outcome in outcomes:
89
+ if isinstance(outcome, RunnerError):
90
+ raise outcome
91
+ return [o for o in outcomes if isinstance(o, ChatResult)]
92
+
93
+ async def run_each(self, requests: Sequence[ChatRequest]) -> list[ChatResult | RunnerError]:
94
+ """Like `run_many`, but each failed request gives its error in place of a result."""
95
+ gate = asyncio.Semaphore(self.concurrency)
96
+ async with self._client() as client:
97
+
98
+ async def one(request: ChatRequest) -> ChatResult | RunnerError:
99
+ async with gate:
100
+ try:
101
+ return await self._run(client, request)
102
+ except RunnerError as error:
103
+ return error
104
+
105
+ return list(await asyncio.gather(*(one(r) for r in requests)))
106
+
107
+ def _client(self) -> httpx.AsyncClient:
108
+ return httpx.AsyncClient(timeout=self.timeout, transport=self._transport)
109
+
110
+ async def _run(self, client: httpx.AsyncClient, request: ChatRequest) -> ChatResult:
111
+ if self.cache is not None:
112
+ cached = self.cache.get(request)
113
+ if cached is not None:
114
+ if self.budget is not None:
115
+ self.budget.record(cached)
116
+ return cached
117
+ result = await self._call(client, request)
118
+ if self.budget is not None:
119
+ self.budget.record(result)
120
+ if self.cache is not None:
121
+ self.cache.put(request, result)
122
+ return result
123
+
124
+ async def _call(self, client: httpx.AsyncClient, request: ChatRequest) -> ChatResult:
125
+ endpoint, model_name = resolve_model(request.model)
126
+ url = f"{endpoint.base_url}/chat/completions"
127
+ body = request_body(request, model_name)
128
+ headers = endpoint.headers()
129
+ start = time.monotonic()
130
+ for attempt in range(1, self.max_attempts + 1):
131
+ wait: float | None = None
132
+ if self.budget is not None:
133
+ self.budget.before_request()
134
+ await self._pace(endpoint)
135
+ try:
136
+ response = await client.post(url, json=body, headers=headers)
137
+ except httpx.TimeoutException:
138
+ reason = f"timed out after {self.timeout:g}s"
139
+ except httpx.TransportError as error:
140
+ reason = f"could not reach {endpoint.base_url} ({type(error).__name__})"
141
+ else:
142
+ if response.status_code == 200:
143
+ return _parse(response.json(), request.model, attempt, time.monotonic() - start)
144
+ reason = f"HTTP {response.status_code}: {_error_text(response)}"
145
+ if response.status_code not in RETRY_STATUSES:
146
+ raise RunnerError(f"{request.model}: {reason}")
147
+ wait = _retry_after(response)
148
+ if attempt == self.max_attempts:
149
+ raise RunnerError(f"{request.model}: {reason} (gave up after {attempt} attempts)")
150
+ await self._sleep(self._backoff(attempt, wait))
151
+ raise AssertionError("unreachable") # pragma: no cover
152
+
153
+ async def _pace(self, endpoint: Endpoint) -> None:
154
+ """Wait for this endpoint's next free slot, so requests stay under its rpm."""
155
+ if endpoint.rpm is None:
156
+ return
157
+ now = self._clock()
158
+ slot = max(now, self._next_slot.get(endpoint.name, now))
159
+ # Book the slot before waiting, so concurrent requests queue up behind it.
160
+ self._next_slot[endpoint.name] = slot + 60.0 / endpoint.rpm
161
+ if slot > now:
162
+ await self._sleep(slot - now)
163
+
164
+ def _backoff(self, attempt: int, retry_after: float | None) -> float:
165
+ """Full jitter: a random wait up to base * 2^(attempt-1), capped at max_delay."""
166
+ ceiling = min(self.max_delay, self.base_delay * 2 ** (attempt - 1))
167
+ delay = self._rng.uniform(0, ceiling)
168
+ if retry_after is not None:
169
+ delay = max(delay, min(retry_after, self.max_delay))
170
+ return delay
171
+
172
+
173
+ def _parse(data: dict[str, Any], model: str, attempts: int, seconds: float) -> ChatResult:
174
+ try:
175
+ choice = data["choices"][0]
176
+ text = _THINK.sub("", choice["message"]["content"] or "", count=1)
177
+ except (KeyError, IndexError, TypeError) as error:
178
+ raise RunnerError(f"{model}: unexpected response shape: {data!r:.200}") from error
179
+ usage = data.get("usage") or {}
180
+ return ChatResult(
181
+ text=text,
182
+ model=model,
183
+ input_tokens=int(usage.get("prompt_tokens", 0)),
184
+ output_tokens=int(usage.get("completion_tokens", 0)),
185
+ finish_reason=str(choice.get("finish_reason") or "unknown"),
186
+ attempts=attempts,
187
+ seconds=seconds,
188
+ )
189
+
190
+
191
+ def _retry_after(response: httpx.Response) -> float | None:
192
+ value = response.headers.get("retry-after")
193
+ if value is None:
194
+ return None
195
+ try:
196
+ return max(0.0, float(value))
197
+ except ValueError:
198
+ return None # an HTTP date; fall back to our own backoff
199
+
200
+
201
+ def _error_text(response: httpx.Response) -> str:
202
+ try:
203
+ data = response.json()
204
+ except ValueError:
205
+ return response.text[:200] or "no body"
206
+ error = data.get("error") if isinstance(data, dict) else None
207
+ if isinstance(error, dict):
208
+ return str(error.get("message", error))[:200]
209
+ if error is not None:
210
+ return str(error)[:200]
211
+ return str(data)[:200]
@@ -0,0 +1,78 @@
1
+ """What goes into a model call and what comes back."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Literal
5
+
6
+ Role = Literal["system", "user", "assistant"]
7
+
8
+
9
+ class RunnerError(Exception):
10
+ """A call failed for good: a non-retryable error, or every attempt failed."""
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class Message:
15
+ """One chat message."""
16
+
17
+ role: Role
18
+ content: str
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class ChatRequest:
23
+ """One model call.
24
+
25
+ `model` is written as `endpoint/model`, for example `ollama/qwen2.5:7b`.
26
+ Everything that can change the answer lives here, so the cache can hash a
27
+ request into its key (ADR 0037). `sample` numbers repeated draws of the
28
+ same request (0, 1, 2, ...): it is part of the cache key but is not sent
29
+ to the model. `reasoning_effort` turns a thinking model's reasoning down
30
+ or off (`none`, `low`, `medium`, `high`). `model_version` names the exact
31
+ weights, such as an Ollama digest (ADR 0039); it is part of the cache key
32
+ but is not sent either.
33
+ """
34
+
35
+ model: str
36
+ messages: tuple[Message, ...]
37
+ temperature: float = 0.0
38
+ max_tokens: int | None = None
39
+ seed: int | None = None
40
+ sample: int = 0
41
+ reasoning_effort: str | None = None
42
+ model_version: str = ""
43
+
44
+ @classmethod
45
+ def ask(
46
+ cls,
47
+ model: str,
48
+ prompt: str,
49
+ system: str | None = None,
50
+ *,
51
+ temperature: float = 0.0,
52
+ max_tokens: int | None = None,
53
+ seed: int | None = None,
54
+ sample: int = 0,
55
+ reasoning_effort: str | None = None,
56
+ ) -> "ChatRequest":
57
+ """A request with one user message and an optional system message."""
58
+ messages = [Message("user", prompt)]
59
+ if system is not None:
60
+ messages.insert(0, Message("system", system))
61
+ return cls(model, tuple(messages), temperature, max_tokens, seed, sample, reasoning_effort)
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class ChatResult:
66
+ """The model's answer and what it took to get it.
67
+
68
+ A result read from the cache has `cached=True`, `attempts=0` and `seconds=0`.
69
+ """
70
+
71
+ text: str
72
+ model: str
73
+ input_tokens: int
74
+ output_tokens: int
75
+ finish_reason: str
76
+ attempts: int
77
+ seconds: float
78
+ cached: bool = False
@@ -1,154 +0,0 @@
1
- """Command-line interface for ModelBump."""
2
-
3
- from datetime import date, datetime
4
- from pathlib import Path
5
- from typing import Annotated
6
-
7
- import typer
8
-
9
- from modelbump import __version__
10
- from modelbump.registry import Status, load_registry
11
- from modelbump.scanner import DEFAULT_SOON_DAYS, scan_path
12
- from modelbump.scanner.report import (
13
- RENDERERS,
14
- OutputFormat,
15
- build_report,
16
- failure_line,
17
- summary_line,
18
- )
19
-
20
- app = typer.Typer(
21
- name="modelbump",
22
- help="Dependabot for LLM models.",
23
- no_args_is_help=True,
24
- )
25
-
26
-
27
- def _version_callback(value: bool) -> None:
28
- if value:
29
- typer.echo(f"modelbump {__version__}")
30
- raise typer.Exit()
31
-
32
-
33
- @app.callback()
34
- def main(
35
- version: Annotated[
36
- bool,
37
- typer.Option(
38
- "--version",
39
- "-V",
40
- help="Show the version and exit.",
41
- callback=_version_callback,
42
- is_eager=True,
43
- ),
44
- ] = False,
45
- ) -> None:
46
- """Dependabot for LLM models."""
47
-
48
-
49
- @app.command()
50
- def scan(
51
- path: Annotated[
52
- Path,
53
- typer.Argument(help="Folder or file to scan.", exists=True, resolve_path=True),
54
- ] = Path("."),
55
- soon_days: Annotated[
56
- int,
57
- typer.Option(
58
- min=0, help="Mark a model RETIRING_SOON when it shuts down within this many days."
59
- ),
60
- ] = DEFAULT_SOON_DAYS,
61
- fail_within: Annotated[
62
- int | None,
63
- typer.Option(
64
- min=0,
65
- help="Exit with code 1 if a model is retired or shuts down within this many days.",
66
- ),
67
- ] = None,
68
- today: Annotated[
69
- datetime | None,
70
- typer.Option(
71
- formats=["%Y-%m-%d"],
72
- help="Check as if today were this day (YYYY-MM-DD), to replay the past.",
73
- ),
74
- ] = None,
75
- output_format: Annotated[
76
- OutputFormat,
77
- typer.Option("--format", help="Report format: text, json or sarif."),
78
- ] = OutputFormat.TEXT,
79
- exclude: Annotated[
80
- list[str] | None,
81
- typer.Option(
82
- help="Skip paths matching this pattern, e.g. 'tests' or '*.ipynb'. Repeat for more.",
83
- ),
84
- ] = None,
85
- skip_comments: Annotated[
86
- bool,
87
- typer.Option(
88
- help="Leave out IDs that only describe a model: comments, docstrings, "
89
- "sentences and price tables.",
90
- ),
91
- ] = False,
92
- skip_tests: Annotated[
93
- bool,
94
- typer.Option(help="Leave out test files and test folders."),
95
- ] = False,
96
- output: Annotated[
97
- Path | None,
98
- typer.Option(help="Write the report to this file instead of the screen.", dir_okay=False),
99
- ] = None,
100
- ) -> None:
101
- """Find the LLM model IDs in a repository's code and config, and how urgent each is."""
102
- registry = load_registry()
103
- day = today.date() if today is not None else date.today()
104
- result = scan_path(path, registry, exclude or (), skip_comments, skip_tests)
105
- report = build_report(result, registry, day, soon_days, fail_within, _root_uri(path))
106
- rendered = RENDERERS[output_format](report)
107
- if output is None:
108
- typer.echo(rendered, nl=False)
109
- else:
110
- output.write_text(rendered, encoding="utf-8")
111
- typer.echo(f"Wrote {output_format.value} report to {output}.")
112
- typer.echo(summary_line(report))
113
- failure = failure_line(report)
114
- if failure is not None:
115
- if output is not None or output_format is not OutputFormat.TEXT:
116
- # Keep the screen output a valid JSON or SARIF document.
117
- typer.echo(failure, err=True)
118
- raise typer.Exit(code=1)
119
-
120
-
121
- def _root_uri(path: Path) -> str:
122
- """The scan root relative to the working directory, for SARIF paths."""
123
- base = path.parent if path.is_file() else path
124
- try:
125
- relative = base.relative_to(Path.cwd()).as_posix()
126
- except ValueError:
127
- return ""
128
- return "" if relative == "." else relative + "/"
129
-
130
-
131
- @app.command()
132
- def check(
133
- model_id: Annotated[str, typer.Argument(help="Model ID, e.g. claude-3-haiku-20240307.")],
134
- ) -> None:
135
- """Look up one model ID in the retirement registry."""
136
- found = load_registry().get(model_id)
137
- if found is None:
138
- typer.echo(f"{model_id}: no retirement announced (not in the registry).")
139
- return
140
- provider, model = found
141
- status = model.status_on(date.today())
142
- if status is Status.RETIRED:
143
- line = f"RETIRED since {model.shutdown}"
144
- if model.shutdown_is_earliest:
145
- line += " (the earliest possible date the provider gave)"
146
- elif model.shutdown_is_earliest:
147
- line = f"{status.value.upper()}, shuts down no earlier than {model.shutdown}"
148
- else:
149
- line = f"{status.value.upper()}, shuts down on {model.shutdown}"
150
- typer.echo(f"{model_id} ({provider.value}): {line}.")
151
- if model.superseded:
152
- typer.echo(f"Rescheduled: {len(model.superseded)} earlier plan(s) were replaced.")
153
- if model.replacement:
154
- typer.echo(f"Suggested replacement: {model.replacement}")
File without changes
File without changes