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.
- {modelbump-0.1.0 → modelbump-0.2.0}/PKG-INFO +2 -1
- {modelbump-0.1.0 → modelbump-0.2.0}/pyproject.toml +2 -1
- {modelbump-0.1.0 → modelbump-0.2.0}/pyproject.toml.orig +2 -1
- modelbump-0.2.0/src/modelbump/cli.py +338 -0
- modelbump-0.2.0/src/modelbump/runner/__init__.py +30 -0
- modelbump-0.2.0/src/modelbump/runner/backends.py +92 -0
- modelbump-0.2.0/src/modelbump/runner/budget.py +109 -0
- modelbump-0.2.0/src/modelbump/runner/cache.py +136 -0
- modelbump-0.2.0/src/modelbump/runner/ollama.py +84 -0
- modelbump-0.2.0/src/modelbump/runner/run.py +211 -0
- modelbump-0.2.0/src/modelbump/runner/types.py +78 -0
- modelbump-0.1.0/src/modelbump/cli.py +0 -154
- {modelbump-0.1.0 → modelbump-0.2.0}/LICENSE +0 -0
- {modelbump-0.1.0 → modelbump-0.2.0}/README.md +0 -0
- {modelbump-0.1.0 → modelbump-0.2.0}/src/modelbump/__init__.py +0 -0
- {modelbump-0.1.0 → modelbump-0.2.0}/src/modelbump/py.typed +0 -0
- {modelbump-0.1.0 → modelbump-0.2.0}/src/modelbump/registry/__init__.py +0 -0
- {modelbump-0.1.0 → modelbump-0.2.0}/src/modelbump/registry/data/anthropic.yaml +0 -0
- {modelbump-0.1.0 → modelbump-0.2.0}/src/modelbump/registry/data/google.yaml +0 -0
- {modelbump-0.1.0 → modelbump-0.2.0}/src/modelbump/registry/data/openai.yaml +0 -0
- {modelbump-0.1.0 → modelbump-0.2.0}/src/modelbump/registry/loader.py +0 -0
- {modelbump-0.1.0 → modelbump-0.2.0}/src/modelbump/registry/models.py +0 -0
- {modelbump-0.1.0 → modelbump-0.2.0}/src/modelbump/scanner/__init__.py +0 -0
- {modelbump-0.1.0 → modelbump-0.2.0}/src/modelbump/scanner/context.py +0 -0
- {modelbump-0.1.0 → modelbump-0.2.0}/src/modelbump/scanner/files.py +0 -0
- {modelbump-0.1.0 → modelbump-0.2.0}/src/modelbump/scanner/ids.py +0 -0
- {modelbump-0.1.0 → modelbump-0.2.0}/src/modelbump/scanner/report.py +0 -0
- {modelbump-0.1.0 → modelbump-0.2.0}/src/modelbump/scanner/resolve.py +0 -0
- {modelbump-0.1.0 → modelbump-0.2.0}/src/modelbump/scanner/scan.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|