hmft 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
hmft/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ """hmft: how much time will this LLM call take?"""
2
+ __version__ = "0.2.0"
3
+
4
+ from .estimate import Estimate, estimate # noqa: E402
5
+ from .length import Band, HeuristicLengthEstimator, LengthEstimator, LengthOptions # noqa: E402
6
+ from .stream import ETA, LiveETA # noqa: E402
7
+ from .throughput import ThroughputEntry, ThroughputTable, lookup # noqa: E402
8
+ from .wrap import Actual, CallResult, TrackedStream, wrap_anthropic, wrap_openai # noqa: E402
9
+
10
+ __all__ = [
11
+ "estimate", "Estimate", "Band", "ETA", "LiveETA",
12
+ "HeuristicLengthEstimator", "LengthEstimator", "LengthOptions",
13
+ "ThroughputEntry", "ThroughputTable", "lookup", "__version__",
14
+ "wrap_openai", "wrap_anthropic", "Actual", "CallResult", "TrackedStream",
15
+ ]
@@ -0,0 +1,272 @@
1
+ {
2
+ "schema_version": 1,
3
+ "updated_at": "2026-09-16T17:58:00Z",
4
+ "entries": {
5
+ "openai/_default": {
6
+ "provider": "openai",
7
+ "model": "_default",
8
+ "measured": true,
9
+ "samples": 20,
10
+ "measured_at": "2026-09-16T07:26:07Z",
11
+ "ttft_s": {
12
+ "p50": 0.725,
13
+ "p90": 2.229
14
+ },
15
+ "tokens_per_s": {
16
+ "p50": 125.2,
17
+ "p10": 90.5
18
+ },
19
+ "ttft_per_1k_prompt_tokens_s": 0.0,
20
+ "reasoning_tokens_p50": null,
21
+ "source": "scripts/benchmark.py (aggregate of measured models)",
22
+ "notes": "provider fallback: median p50 / worst slow side across gpt-4.1, gpt-4.1-mini"
23
+ },
24
+ "openai/gpt-4.1-mini": {
25
+ "provider": "openai",
26
+ "model": "gpt-4.1-mini",
27
+ "measured": true,
28
+ "samples": 10,
29
+ "measured_at": "2026-09-16T07:21:48Z",
30
+ "ttft_s": {
31
+ "p50": 0.69,
32
+ "p90": 1.622
33
+ },
34
+ "tokens_per_s": {
35
+ "p50": 139.2,
36
+ "p10": 119.1
37
+ },
38
+ "ttft_per_1k_prompt_tokens_s": 0.0,
39
+ "reasoning_tokens_p50": null,
40
+ "source": "scripts/benchmark.py",
41
+ "notes": ""
42
+ },
43
+ "openai/gpt-4.1": {
44
+ "provider": "openai",
45
+ "model": "gpt-4.1",
46
+ "measured": true,
47
+ "samples": 10,
48
+ "measured_at": "2026-09-16T07:26:07Z",
49
+ "ttft_s": {
50
+ "p50": 0.76,
51
+ "p90": 2.229
52
+ },
53
+ "tokens_per_s": {
54
+ "p50": 111.3,
55
+ "p10": 90.5
56
+ },
57
+ "ttft_per_1k_prompt_tokens_s": 0.0,
58
+ "reasoning_tokens_p50": null,
59
+ "source": "scripts/benchmark.py",
60
+ "notes": "slope from median method (raw samples of this run not re-processed)"
61
+ },
62
+ "openai/gpt-5-mini": {
63
+ "provider": "openai",
64
+ "model": "gpt-5-mini",
65
+ "measured": true,
66
+ "samples": 10,
67
+ "measured_at": "2026-09-16T07:21:48Z",
68
+ "ttft_s": {
69
+ "p50": 22.94,
70
+ "p90": 39.774
71
+ },
72
+ "tokens_per_s": {
73
+ "p50": 75.2,
74
+ "p10": 67.1
75
+ },
76
+ "ttft_per_1k_prompt_tokens_s": 1.9401,
77
+ "reasoning_tokens_p50": 1824,
78
+ "source": "scripts/benchmark.py",
79
+ "notes": "reasoning model: ttft includes hidden thinking time for this prompt shape; slope unreliable"
80
+ },
81
+ "openai/gpt-5@low": {
82
+ "provider": "openai",
83
+ "model": "gpt-5@low",
84
+ "measured": true,
85
+ "samples": 10,
86
+ "measured_at": "2026-09-16T07:21:48Z",
87
+ "ttft_s": {
88
+ "p50": 9.5,
89
+ "p90": 17.52
90
+ },
91
+ "tokens_per_s": {
92
+ "p50": 123.0,
93
+ "p10": 69.4
94
+ },
95
+ "ttft_per_1k_prompt_tokens_s": 5.9433,
96
+ "reasoning_tokens_p50": 1312,
97
+ "source": "scripts/benchmark.py",
98
+ "notes": "reasoning model at reasoning_effort=low; reasoning model: ttft includes hidden thinking time for this prompt shape; slope unreliable"
99
+ },
100
+ "openai/gpt-5@medium": {
101
+ "provider": "openai",
102
+ "model": "gpt-5@medium",
103
+ "measured": true,
104
+ "samples": 10,
105
+ "measured_at": "2026-09-16T07:21:48Z",
106
+ "ttft_s": {
107
+ "p50": 24.51,
108
+ "p90": 26.682
109
+ },
110
+ "tokens_per_s": {
111
+ "p50": 169.1,
112
+ "p10": 149.6
113
+ },
114
+ "ttft_per_1k_prompt_tokens_s": 0.0,
115
+ "reasoning_tokens_p50": 1760,
116
+ "source": "scripts/benchmark.py",
117
+ "notes": "reasoning model at reasoning_effort=medium; reasoning model: ttft includes hidden thinking time for this prompt shape; slope unreliable"
118
+ },
119
+ "anthropic/_default": {
120
+ "provider": "anthropic",
121
+ "model": "_default",
122
+ "measured": true,
123
+ "samples": 50,
124
+ "measured_at": "2026-09-16T17:58:00Z",
125
+ "ttft_s": {
126
+ "p50": 0.765,
127
+ "p90": 1.355
128
+ },
129
+ "tokens_per_s": {
130
+ "p50": 60.1,
131
+ "p10": 34.6
132
+ },
133
+ "ttft_per_1k_prompt_tokens_s": 0.2326,
134
+ "reasoning_tokens_p50": null,
135
+ "source": "scripts/benchmark.py (aggregate of measured models)",
136
+ "notes": "provider fallback: median p50 / worst slow side across claude-haiku-4-5, claude-sonnet-4-5"
137
+ },
138
+ "anthropic/claude-sonnet-4-5": {
139
+ "provider": "anthropic",
140
+ "model": "claude-sonnet-4-5",
141
+ "measured": true,
142
+ "samples": 40,
143
+ "measured_at": "2026-09-16T17:58:00Z",
144
+ "ttft_s": {
145
+ "p50": 0.86,
146
+ "p90": 1.355
147
+ },
148
+ "tokens_per_s": {
149
+ "p50": 36.8,
150
+ "p10": 34.6
151
+ },
152
+ "ttft_per_1k_prompt_tokens_s": 0.2326,
153
+ "reasoning_tokens_p50": null,
154
+ "source": "scripts/benchmark.py",
155
+ "notes": "two 10-run benchmarks pooled (17:49Z and 17:58Z)"
156
+ },
157
+ "anthropic/claude-haiku-4-5": {
158
+ "provider": "anthropic",
159
+ "model": "claude-haiku-4-5",
160
+ "measured": true,
161
+ "samples": 10,
162
+ "measured_at": "2026-09-16T07:21:48Z",
163
+ "ttft_s": {
164
+ "p50": 0.67,
165
+ "p90": 0.686
166
+ },
167
+ "tokens_per_s": {
168
+ "p50": 83.4,
169
+ "p10": 79.5
170
+ },
171
+ "ttft_per_1k_prompt_tokens_s": 0.0,
172
+ "reasoning_tokens_p50": null,
173
+ "source": "scripts/benchmark.py",
174
+ "notes": ""
175
+ },
176
+ "openrouter/openai/gpt-4.1-mini": {
177
+ "provider": "openrouter",
178
+ "model": "openai/gpt-4.1-mini",
179
+ "measured": true,
180
+ "samples": 10,
181
+ "measured_at": "2026-09-16T07:21:48Z",
182
+ "ttft_s": {
183
+ "p50": 0.81,
184
+ "p90": 1.17
185
+ },
186
+ "tokens_per_s": {
187
+ "p50": 108.1,
188
+ "p10": 82.7
189
+ },
190
+ "ttft_per_1k_prompt_tokens_s": 0.171,
191
+ "reasoning_tokens_p50": null,
192
+ "source": "scripts/benchmark.py",
193
+ "notes": ""
194
+ },
195
+ "openrouter/openai/gpt-4.1": {
196
+ "provider": "openrouter",
197
+ "model": "openai/gpt-4.1",
198
+ "measured": true,
199
+ "samples": 10,
200
+ "measured_at": "2026-09-16T07:21:48Z",
201
+ "ttft_s": {
202
+ "p50": 0.85,
203
+ "p90": 1.024
204
+ },
205
+ "tokens_per_s": {
206
+ "p50": 138.6,
207
+ "p10": 87.2
208
+ },
209
+ "ttft_per_1k_prompt_tokens_s": 0.1603,
210
+ "reasoning_tokens_p50": null,
211
+ "source": "scripts/benchmark.py",
212
+ "notes": ""
213
+ },
214
+ "openrouter/openai/gpt-5-mini": {
215
+ "provider": "openrouter",
216
+ "model": "openai/gpt-5-mini",
217
+ "measured": true,
218
+ "samples": 10,
219
+ "measured_at": "2026-09-16T07:33:42Z",
220
+ "ttft_s": {
221
+ "p50": 32.84,
222
+ "p90": 41.578
223
+ },
224
+ "tokens_per_s": {
225
+ "p50": 84.9,
226
+ "p10": 55.9
227
+ },
228
+ "ttft_per_1k_prompt_tokens_s": 2.3944,
229
+ "reasoning_tokens_p50": 1536,
230
+ "source": "scripts/benchmark.py",
231
+ "notes": "reasoning model: ttft includes hidden thinking time for this prompt shape; slope unreliable"
232
+ },
233
+ "openrouter/anthropic/claude-sonnet-4.5": {
234
+ "provider": "openrouter",
235
+ "model": "anthropic/claude-sonnet-4.5",
236
+ "measured": true,
237
+ "samples": 6,
238
+ "measured_at": "2026-09-16T07:21:48Z",
239
+ "ttft_s": {
240
+ "p50": 1.96,
241
+ "p90": 3.024
242
+ },
243
+ "tokens_per_s": {
244
+ "p50": 39.8,
245
+ "p10": 34.2
246
+ },
247
+ "ttft_per_1k_prompt_tokens_s": 0.3149,
248
+ "reasoning_tokens_p50": null,
249
+ "source": "scripts/benchmark.py",
250
+ "notes": ""
251
+ },
252
+ "openrouter/anthropic/claude-haiku-4.5": {
253
+ "provider": "openrouter",
254
+ "model": "anthropic/claude-haiku-4.5",
255
+ "measured": true,
256
+ "samples": 10,
257
+ "measured_at": "2026-09-16T07:33:42Z",
258
+ "ttft_s": {
259
+ "p50": 1.13,
260
+ "p90": 1.334
261
+ },
262
+ "tokens_per_s": {
263
+ "p50": 88.7,
264
+ "p10": 85.2
265
+ },
266
+ "ttft_per_1k_prompt_tokens_s": 0.0339,
267
+ "reasoning_tokens_p50": null,
268
+ "source": "scripts/benchmark.py",
269
+ "notes": ""
270
+ }
271
+ }
272
+ }
hmft/estimate.py ADDED
@@ -0,0 +1,113 @@
1
+ """Pre-call estimate: how long will this call take?
2
+
3
+ total ≈ TTFT(prompt_tokens, model, provider) + output_tokens / tokens_per_s
4
+
5
+ Every number is a Band(p50, p90) in the *time* sense: p90 is the slow side.
6
+ The throughput table stores tokens_per_s as p50/p10 because for throughput
7
+ the slow side is the LOW percentile. So:
8
+
9
+ time p50 = ttft.p50 + output.p50 / tps.p50
10
+ time p90 = ttft.p90 + output.p90 / tps.p10 <- throughput p10, not p90
11
+
12
+ If the table has no usable entry for the model or its provider default, the
13
+ token estimate is still returned and the time fields are None with
14
+ basis="unmeasured". Nothing is invented.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import asdict, dataclass
19
+ from typing import Any, Iterable, Optional
20
+
21
+ from .length import DEFAULT_LENGTH_ESTIMATOR, Band, LengthEstimator, LengthOptions
22
+ from .throughput import ThroughputEntry, lookup
23
+ from .tokens import count_messages
24
+
25
+ # Reasoning models emit hidden thinking tokens that are billed and take wall
26
+ # time but are not in the visible output. v1 does not model them; the
27
+ # benchmark measures the damage per effort level (see throughput.json notes).
28
+ REASONING_EFFORT_SUFFIX = "@"
29
+ # Bare reasoning-model calls run at the provider's default effort. Rows are keyed per effort, so a call
30
+ # with no effort set falls back to the @medium row when no bare row exists (gpt-5 -> gpt-5@medium).
31
+ DEFAULT_REASONING_EFFORT = "medium"
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Estimate:
36
+ ttft_s: Optional[Band]
37
+ output_tokens: Band
38
+ total_s: Optional[Band]
39
+ prompt_tokens: int
40
+ model: str
41
+ provider: str
42
+ basis: str # "measured" | "provider_default" | "unmeasured"
43
+ basis_key: str # throughput table key that was used, "" if none
44
+ reasoning_effort: Optional[str] = None # effort requested, or the one the table row implies (gpt-5 -> medium); None for non-reasoning
45
+ effort_source: Optional[str] = None # "requested" | "default" when reasoning_effort is set, else None
46
+
47
+ def to_dict(self) -> dict[str, Any]:
48
+ return asdict(self)
49
+
50
+ def __str__(self) -> str:
51
+ if self.total_s is None:
52
+ return (f"~{self.output_tokens.p50:.0f} output tokens (p90 {self.output_tokens.p90:.0f}); "
53
+ f"no timing for {self.provider}/{self.model} (unmeasured)")
54
+ return (f"~{self.total_s.p50:.1f}s (p90 {self.total_s.p90:.1f}s): "
55
+ f"ttft {self.ttft_s.p50:.2f}s, ~{self.output_tokens.p50:.0f} tokens [{self.basis}]")
56
+
57
+
58
+ def table_key_for(model: str, reasoning_effort: Optional[str]) -> str:
59
+ """gpt-5 + effort 'low' -> 'gpt-5@low'. Table entries for reasoning models are per effort."""
60
+ return f"{model}{REASONING_EFFORT_SUFFIX}{reasoning_effort}" if reasoning_effort else model
61
+
62
+
63
+ def table_keys_for(model: str, reasoning_effort: Optional[str]) -> list[str]:
64
+ """Lookup order. With an effort: just that row. Without: the bare row, then @medium."""
65
+ if reasoning_effort:
66
+ return [table_key_for(model, reasoning_effort)]
67
+ return [model, table_key_for(model, DEFAULT_REASONING_EFFORT)]
68
+
69
+
70
+ def _ttft_band(entry: ThroughputEntry, prompt_tokens: int) -> Band:
71
+ slope = entry.ttft_per_1k_prompt_tokens_s or 0.0
72
+ extra = slope * prompt_tokens / 1000.0
73
+ return Band(entry.ttft_p50_s + extra, entry.ttft_p90_s + extra * 1.5)
74
+
75
+
76
+ def estimate(
77
+ messages: Iterable[Any],
78
+ model: str,
79
+ provider: str = "openai",
80
+ *,
81
+ system: Optional[str] = None,
82
+ max_tokens: Optional[int] = None,
83
+ json_mode: bool = False,
84
+ tool_choice_forced: bool = False,
85
+ reasoning_effort: Optional[str] = None,
86
+ length_estimator: Optional[LengthEstimator] = None,
87
+ throughput_entry: Optional[ThroughputEntry] = None,
88
+ ) -> Estimate:
89
+ """Estimate time and output size for a chat call before sending it.
90
+
91
+ `throughput_entry` lets callers (and tests) bypass the table lookup.
92
+ `length_estimator` is the v2 plug point.
93
+ """
94
+ msgs = list(messages)
95
+ prompt_tokens = count_messages(msgs, model, provider, system=system)
96
+ options = LengthOptions(max_tokens=max_tokens, json_mode=json_mode, tool_choice_forced=tool_choice_forced, system=system)
97
+ output = (length_estimator or DEFAULT_LENGTH_ESTIMATOR).estimate(msgs, model, options)
98
+
99
+ entry = throughput_entry if throughput_entry is not None else lookup(model, provider, table_keys_for(model, reasoning_effort))
100
+ effort, source = reasoning_effort, ("requested" if reasoning_effort else None)
101
+ if effort is None and REASONING_EFFORT_SUFFIX in entry.basis_key:
102
+ # the caller set nothing; the provider's default effort applies, and the table row says which one we assumed
103
+ effort, source = entry.basis_key.rsplit(REASONING_EFFORT_SUFFIX, 1)[1], "default"
104
+ if not entry.usable:
105
+ return Estimate(None, output, None, prompt_tokens, model, provider, "unmeasured", entry.basis_key, effort, source)
106
+
107
+ ttft = _ttft_band(entry, prompt_tokens)
108
+ total = Band(
109
+ ttft.p50 + output.p50 / entry.tps_p50,
110
+ ttft.p90 + output.p90 / entry.tps_p10, # slow throughput -> long time
111
+ )
112
+ basis = "provider_default" if entry.model == "_default" else "measured"
113
+ return Estimate(ttft, output, total, prompt_tokens, model, provider, basis, entry.basis_key, effort, source)