responsible-request 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- responsible_request/__init__.py +94 -0
- responsible_request/_http.py +31 -0
- responsible_request/analysis.py +81 -0
- responsible_request/cache.py +59 -0
- responsible_request/client.py +169 -0
- responsible_request/config.py +266 -0
- responsible_request/controller.py +79 -0
- responsible_request/cost.py +96 -0
- responsible_request/estimator.py +113 -0
- responsible_request/helpers/__init__.py +21 -0
- responsible_request/helpers/batch.py +127 -0
- responsible_request/helpers/messages.py +18 -0
- responsible_request/helpers/params.py +18 -0
- responsible_request/helpers/structured.py +150 -0
- responsible_request/limiter.py +165 -0
- responsible_request/logfiles.py +120 -0
- responsible_request/logging.py +183 -0
- responsible_request/providers.py +105 -0
- responsible_request/py.typed +0 -0
- responsible_request/records.py +265 -0
- responsible_request/runs.py +116 -0
- responsible_request/sources.py +207 -0
- responsible_request/throttle.py +229 -0
- responsible_request/transport.py +376 -0
- responsible_request-0.4.0.dist-info/METADATA +319 -0
- responsible_request-0.4.0.dist-info/RECORD +28 -0
- responsible_request-0.4.0.dist-info/WHEEL +4 -0
- responsible_request-0.4.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""User-facing configuration objects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Mapping
|
|
6
|
+
from dataclasses import dataclass, field, replace
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
9
|
+
|
|
10
|
+
from .logfiles import COMPRESSIONS, compression_of
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from .estimator import LoadEstimator
|
|
14
|
+
from .records import RequestRecord
|
|
15
|
+
|
|
16
|
+
Metric = Literal["latency_per_token", "latency", "ttfb"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class ThrottleConfig:
|
|
21
|
+
"""How requests are paced and how the pace adapts to the observed load.
|
|
22
|
+
|
|
23
|
+
Requests are spaced evenly at the current RPM (no bursts). The RPM starts at ``start_rpm``,
|
|
24
|
+
ramps up towards ``max_rpm`` while latency stays close to the baseline, and drops to
|
|
25
|
+
``min_rpm`` as soon as latency reaches ``high_ratio`` x baseline (or the server returns
|
|
26
|
+
429/5xx/timeouts).
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
max_rpm: float = 15
|
|
30
|
+
"""Upper bound on requests per minute when the endpoint is idle."""
|
|
31
|
+
min_rpm: float = 1
|
|
32
|
+
"""Requests per minute while other users are active. Also serves as probing rate."""
|
|
33
|
+
start_rpm: float = 2
|
|
34
|
+
"""Initial rate while the latency baseline is being established (clamped to [min, max])."""
|
|
35
|
+
max_concurrency: int = 32
|
|
36
|
+
"""Maximum number of in-flight requests per model, regardless of RPM."""
|
|
37
|
+
|
|
38
|
+
high_ratio: float = 3.0
|
|
39
|
+
"""Throttle to ``min_rpm`` when current latency >= ``high_ratio`` x baseline."""
|
|
40
|
+
recover_ratio: float = 1.5
|
|
41
|
+
"""Ramp up only while current latency < ``recover_ratio`` x baseline (dead band in between)."""
|
|
42
|
+
cooldown_s: float = 120
|
|
43
|
+
"""Minimum time spent at ``min_rpm`` after the last high-load signal."""
|
|
44
|
+
ramp_factor: float = 1.5
|
|
45
|
+
"""Multiplicative RPM increase per ramp step."""
|
|
46
|
+
ramp_interval_s: float = 30
|
|
47
|
+
"""Minimum time between two ramp steps."""
|
|
48
|
+
|
|
49
|
+
metric: Metric | Callable[[RequestRecord], float | None] = "latency_per_token"
|
|
50
|
+
"""Latency signal: ``latency_per_token`` (latency / max(completion tokens, ``min_tokens``);
|
|
51
|
+
uses time-to-first-byte for streamed requests), ``latency`` (raw), ``ttfb``, or a callable."""
|
|
52
|
+
min_tokens: int = 16
|
|
53
|
+
"""Floor for the token count used by the ``latency_per_token`` metric."""
|
|
54
|
+
window: int = 10
|
|
55
|
+
"""Number of recent observations whose median forms the current latency signal."""
|
|
56
|
+
warmup_requests: int = 20
|
|
57
|
+
"""Observations required before the baseline is trusted and the RPM may ramp up."""
|
|
58
|
+
baseline: float | None = None
|
|
59
|
+
"""Pin the baseline (in units of ``metric``) instead of estimating it."""
|
|
60
|
+
baseline_percentile: float = 10
|
|
61
|
+
"""Baseline = this percentile of the observations inside ``baseline_window_s``."""
|
|
62
|
+
baseline_window_s: float = 1800
|
|
63
|
+
"""Time span of observations used for the baseline (samples taken while throttled are
|
|
64
|
+
excluded, so the baseline freezes during high-load periods)."""
|
|
65
|
+
|
|
66
|
+
observe_paths: tuple[str, ...] = ("/chat/completions", "/embeddings")
|
|
67
|
+
"""Endpoints whose latency feeds the load estimate. Other endpoints are paced and logged,
|
|
68
|
+
and react to 429/5xx, but do not adapt to latency."""
|
|
69
|
+
ignore_server_tools: bool = True
|
|
70
|
+
"""Exclude requests using server-side tools (e.g. MCP, web search) from the load estimate,
|
|
71
|
+
since their latency includes external calls."""
|
|
72
|
+
adaptive: bool = True
|
|
73
|
+
"""If False, requests are paced at a constant ``max_rpm``; a 429 then pauses the lane (see
|
|
74
|
+
``backoff_initial_s``) instead of lowering the rate."""
|
|
75
|
+
backoff_initial_s: float = 1.0
|
|
76
|
+
"""Fixed mode: pause after a 429 without a usable ``Retry-After`` header; doubles with every
|
|
77
|
+
further 429 until a request succeeds."""
|
|
78
|
+
backoff_max_s: float = 60.0
|
|
79
|
+
"""Fixed mode: upper bound of that backoff (an explicit ``Retry-After`` is obeyed as is)."""
|
|
80
|
+
backoff_jitter_s: float = 1.0
|
|
81
|
+
"""Fixed mode: random extra time in ``[0, backoff_jitter_s]`` added to every pause."""
|
|
82
|
+
estimator_factory: Callable[[ThrottleConfig], LoadEstimator] | None = None
|
|
83
|
+
"""Custom load estimator (see :class:`responsible_request.estimator.LoadEstimator`)."""
|
|
84
|
+
|
|
85
|
+
def __post_init__(self) -> None:
|
|
86
|
+
if not 0 < self.min_rpm <= self.max_rpm:
|
|
87
|
+
raise ValueError("require 0 < min_rpm <= max_rpm")
|
|
88
|
+
if not 1 < self.recover_ratio < self.high_ratio:
|
|
89
|
+
raise ValueError("require 1 < recover_ratio < high_ratio")
|
|
90
|
+
if self.ramp_factor <= 1:
|
|
91
|
+
raise ValueError("ramp_factor must be > 1")
|
|
92
|
+
if self.max_concurrency < 1 or self.window < 1:
|
|
93
|
+
raise ValueError("max_concurrency and window must be >= 1")
|
|
94
|
+
if not 0 < self.baseline_percentile < 100:
|
|
95
|
+
raise ValueError("baseline_percentile must be in (0, 100)")
|
|
96
|
+
if not 0 <= self.backoff_initial_s <= self.backoff_max_s or self.backoff_jitter_s < 0:
|
|
97
|
+
raise ValueError("require 0 <= backoff_initial_s <= backoff_max_s and jitter >= 0")
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def initial_rpm(self) -> float:
|
|
101
|
+
if not self.adaptive:
|
|
102
|
+
return self.max_rpm
|
|
103
|
+
return min(self.max_rpm, max(self.min_rpm, self.start_rpm))
|
|
104
|
+
|
|
105
|
+
@classmethod
|
|
106
|
+
def fixed(cls, rpm: float, max_concurrency: int = 32, **kwargs: Any) -> ThrottleConfig:
|
|
107
|
+
"""A plain rate limiter at a constant ``rpm`` (no load adaptation).
|
|
108
|
+
|
|
109
|
+
A 429 pauses the affected model for ``Retry-After`` (or an exponential backoff) plus
|
|
110
|
+
jitter. Further keyword arguments (e.g. ``backoff_max_s``) are passed to the constructor.
|
|
111
|
+
"""
|
|
112
|
+
return cls(
|
|
113
|
+
max_rpm=rpm,
|
|
114
|
+
min_rpm=rpm,
|
|
115
|
+
start_rpm=rpm,
|
|
116
|
+
max_concurrency=max_concurrency,
|
|
117
|
+
adaptive=False,
|
|
118
|
+
**kwargs,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
FIELD_GROUPS = (
|
|
123
|
+
"timing",
|
|
124
|
+
"usage",
|
|
125
|
+
"throttle",
|
|
126
|
+
"response_meta",
|
|
127
|
+
"params",
|
|
128
|
+
"request_body",
|
|
129
|
+
"response_body",
|
|
130
|
+
)
|
|
131
|
+
"""Record field groups that can be toggled in :class:`LogConfig`. Core fields (id, timestamp,
|
|
132
|
+
path, model, status, error, tags) are always logged."""
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(frozen=True)
|
|
136
|
+
class LogConfig:
|
|
137
|
+
"""Where and what to log.
|
|
138
|
+
|
|
139
|
+
The console only receives throttle changes, a periodic summary, and warnings. Every request
|
|
140
|
+
is written as one structured record to the configured JSONL file and/or SQLite database.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
jsonl: str | Path | None = None
|
|
144
|
+
"""Append one JSON object per request to this file."""
|
|
145
|
+
sqlite: str | Path | None = None
|
|
146
|
+
"""Insert one row per request into the ``requests`` table of this SQLite database."""
|
|
147
|
+
fields: Mapping[str, bool] = field(default_factory=dict)
|
|
148
|
+
"""Toggle record field groups (see ``FIELD_GROUPS``); groups not mentioned are logged."""
|
|
149
|
+
summary_interval_s: float | None = 60
|
|
150
|
+
"""Emit an INFO summary line per model at most this often (None disables)."""
|
|
151
|
+
console_level: str | None = None
|
|
152
|
+
"""If set, replace loguru's default stderr handler with one at this level. If None, the
|
|
153
|
+
console handlers are left untouched."""
|
|
154
|
+
rotation: str | int | None = "500 MB"
|
|
155
|
+
"""Passed to loguru for the JSONL file."""
|
|
156
|
+
compression: str | None = None
|
|
157
|
+
"""Compress JSONL segments once they are rotated away: ``"gz"``, ``"bz2"``, ``"xz"`` or
|
|
158
|
+
``"zst"`` (zstd: Python 3.14+ or ``pip install responsible-request[zstd]``). Without
|
|
159
|
+
rotation, the file is compressed when its sink is removed. The cache, cost budget and
|
|
160
|
+
:func:`~responsible_request.load_records` read compressed segments too."""
|
|
161
|
+
|
|
162
|
+
def __post_init__(self) -> None:
|
|
163
|
+
unknown = set(self.fields) - set(FIELD_GROUPS)
|
|
164
|
+
if unknown:
|
|
165
|
+
raise ValueError(f"unknown field groups {sorted(unknown)}; valid: {FIELD_GROUPS}")
|
|
166
|
+
if self.compression is not None and self.compression not in COMPRESSIONS:
|
|
167
|
+
raise ValueError(f"unknown compression {self.compression!r}; valid: {COMPRESSIONS}")
|
|
168
|
+
if self.jsonl is not None and compression_of(Path(self.jsonl)):
|
|
169
|
+
raise ValueError("log to a plain .jsonl file and set compression= for its segments")
|
|
170
|
+
|
|
171
|
+
def enabled(self, group: str) -> bool:
|
|
172
|
+
return self.fields.get(group, True)
|
|
173
|
+
|
|
174
|
+
@classmethod
|
|
175
|
+
def minimal(cls, **kwargs: Any) -> LogConfig:
|
|
176
|
+
"""Log timing, usage and throttle state, but no parameters or message contents."""
|
|
177
|
+
off = {"params": False, "request_body": False, "response_body": False}
|
|
178
|
+
return cls(fields=off, **kwargs)
|
|
179
|
+
|
|
180
|
+
@classmethod
|
|
181
|
+
def full(cls, **kwargs: Any) -> LogConfig:
|
|
182
|
+
"""Log everything (the default)."""
|
|
183
|
+
return cls(**kwargs)
|
|
184
|
+
|
|
185
|
+
def with_fields(self, **toggles: bool) -> LogConfig:
|
|
186
|
+
return replace(self, fields={**self.fields, **toggles})
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@dataclass(frozen=True)
|
|
190
|
+
class CacheConfig:
|
|
191
|
+
"""Answer requests from previously logged records instead of sending them again.
|
|
192
|
+
|
|
193
|
+
A request is served from the cache if a successful (HTTP 200) record with the same cache key
|
|
194
|
+
exists. The key is a hash of the URL and the complete JSON request body (model, messages and
|
|
195
|
+
all parameters, after ``default_params`` are applied), plus the values of ``key_tags``.
|
|
196
|
+
Streamed requests are never served from the cache. Cache hits are logged with
|
|
197
|
+
``cache_hit=True`` and neither wait for nor affect the throttle.
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
sqlite: str | Path | None = None
|
|
201
|
+
"""Read cached responses from this SQLite database (defaults to ``LogConfig.sqlite``)."""
|
|
202
|
+
jsonl: str | Path | None = None
|
|
203
|
+
"""Read cached responses from this JSONL file (defaults to ``LogConfig.jsonl``)."""
|
|
204
|
+
key_tags: tuple[str, ...] = ()
|
|
205
|
+
"""Tags (see :func:`responsible_request.tags`) that are part of the cache key, e.g.
|
|
206
|
+
``("sample",)`` to draw several independent samples for the same request."""
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@dataclass(frozen=True)
|
|
210
|
+
class Price:
|
|
211
|
+
"""Token prices in USD per million tokens (used when the response reports no cost)."""
|
|
212
|
+
|
|
213
|
+
input: float
|
|
214
|
+
output: float
|
|
215
|
+
cached_input: float | None = None
|
|
216
|
+
"""Price of cached prompt tokens; defaults to ``input``."""
|
|
217
|
+
|
|
218
|
+
def cost(
|
|
219
|
+
self, prompt_tokens: int | None, completion_tokens: int | None, cached_tokens: int | None
|
|
220
|
+
) -> float:
|
|
221
|
+
prompt, cached = prompt_tokens or 0, min(cached_tokens or 0, prompt_tokens or 0)
|
|
222
|
+
cached_price = self.input if self.cached_input is None else self.cached_input
|
|
223
|
+
total = (prompt - cached) * self.input + cached * cached_price
|
|
224
|
+
return (total + (completion_tokens or 0) * self.output) / 1_000_000
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@dataclass(frozen=True)
|
|
228
|
+
class CostConfig:
|
|
229
|
+
"""Track the cost of every request and optionally stop sending requests above a budget.
|
|
230
|
+
|
|
231
|
+
The cost of a request is the one reported by the provider (OpenRouter's ``usage.cost``, the
|
|
232
|
+
LiteLLM ``x-litellm-response-cost`` header, or a custom extractor), else it is computed from
|
|
233
|
+
``prices`` and the token usage. Cache hits cost nothing.
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
budget_usd: float | None = None
|
|
237
|
+
"""Refuse new requests (raising :class:`responsible_request.BudgetExceeded`) once this much
|
|
238
|
+
has been spent. Requests already in flight still complete, so the budget can be overshot by
|
|
239
|
+
their cost. Cache hits are always served."""
|
|
240
|
+
prices: Mapping[str, Price] = field(default_factory=dict)
|
|
241
|
+
"""Fallback prices per model name, for providers that don't report a cost."""
|
|
242
|
+
include_logged: bool = True
|
|
243
|
+
"""Count the cost already recorded in the request log (``LogConfig.sqlite``/``jsonl``), so
|
|
244
|
+
that restarting a script does not reset the budget."""
|
|
245
|
+
|
|
246
|
+
def __post_init__(self) -> None:
|
|
247
|
+
if self.budget_usd is not None and self.budget_usd < 0:
|
|
248
|
+
raise ValueError("budget_usd must be >= 0")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@dataclass(frozen=True)
|
|
252
|
+
class RunConfig:
|
|
253
|
+
"""Describes a run: one client (or ``http_client``) from creation to close.
|
|
254
|
+
|
|
255
|
+
Every request record carries the ``run_id`` of its run. On the first request a run manifest
|
|
256
|
+
(versions, git commit, command line, all configurations, ``default_params``, ``metadata``) is
|
|
257
|
+
written to the ``runs`` table of the SQLite log, or to ``<name>.runs.jsonl`` next to the
|
|
258
|
+
JSONL log.
|
|
259
|
+
"""
|
|
260
|
+
|
|
261
|
+
name: str | None = None
|
|
262
|
+
"""Human-readable name of the run, e.g. the experiment."""
|
|
263
|
+
metadata: Mapping[str, Any] = field(default_factory=dict)
|
|
264
|
+
"""Anything else worth recording (dataset version, hyperparameters, notes, ...)."""
|
|
265
|
+
capture_git: bool = True
|
|
266
|
+
"""Record the git commit and dirty flag of the working directory."""
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Decide the target request rate from the load signal."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from enum import Enum
|
|
9
|
+
|
|
10
|
+
from .config import ThrottleConfig
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class State(str, Enum):
|
|
14
|
+
WARMUP = "warmup" # establishing the baseline at start_rpm
|
|
15
|
+
NORMAL = "normal" # endpoint looks idle: ramp up to max_rpm
|
|
16
|
+
THROTTLED = "throttled" # other users detected: stay at min_rpm for at least the cooldown
|
|
17
|
+
RECOVERING = "recovering" # load cleared: ramping back up
|
|
18
|
+
FIXED = "fixed" # adaptation disabled
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class Transition:
|
|
23
|
+
old_state: State
|
|
24
|
+
new_state: State
|
|
25
|
+
old_rpm: float
|
|
26
|
+
new_rpm: float
|
|
27
|
+
reason: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ThrottleController:
|
|
31
|
+
"""State machine with hysteresis mapping load ratios to a requests-per-minute target.
|
|
32
|
+
|
|
33
|
+
* ratio >= ``high_ratio`` or a congestion error: drop to ``min_rpm`` immediately.
|
|
34
|
+
* ``recover_ratio`` <= ratio < ``high_ratio``: hold the current rate (dead band).
|
|
35
|
+
* ratio < ``recover_ratio``: multiply the rate by ``ramp_factor`` every ``ramp_interval_s``
|
|
36
|
+
up to ``max_rpm`` (after the cooldown, if throttled).
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, config: ThrottleConfig, clock: Callable[[], float] = time.monotonic) -> None:
|
|
40
|
+
self.config = config
|
|
41
|
+
self._clock = clock
|
|
42
|
+
self.state = State.WARMUP if config.adaptive else State.FIXED
|
|
43
|
+
self.rpm = config.initial_rpm
|
|
44
|
+
self._throttled_at = -float("inf")
|
|
45
|
+
self._last_ramp = clock()
|
|
46
|
+
|
|
47
|
+
def update(self, ratio: float | None, *, congested: bool = False) -> Transition | None:
|
|
48
|
+
"""Process one observation. Returns the transition if the state or the rate changed."""
|
|
49
|
+
if self.state is State.FIXED:
|
|
50
|
+
return None
|
|
51
|
+
cfg, now = self.config, self._clock()
|
|
52
|
+
old_state, old_rpm = self.state, self.rpm
|
|
53
|
+
reason = ""
|
|
54
|
+
|
|
55
|
+
if congested or (ratio is not None and ratio >= cfg.high_ratio):
|
|
56
|
+
self._throttled_at = now
|
|
57
|
+
self.state, self.rpm = State.THROTTLED, cfg.min_rpm
|
|
58
|
+
reason = "server errors" if congested else f"load {ratio:.1f}x baseline"
|
|
59
|
+
elif self.state is State.WARMUP:
|
|
60
|
+
if ratio is not None:
|
|
61
|
+
self.state, self._last_ramp = State.NORMAL, now
|
|
62
|
+
reason = "baseline established"
|
|
63
|
+
elif self.state is State.THROTTLED:
|
|
64
|
+
cooled_down = now - self._throttled_at >= cfg.cooldown_s
|
|
65
|
+
if cooled_down and ratio is not None and ratio < cfg.recover_ratio:
|
|
66
|
+
self.state, self._last_ramp = State.RECOVERING, now
|
|
67
|
+
reason = f"load {ratio:.1f}x baseline, recovering"
|
|
68
|
+
elif ratio is not None and ratio < cfg.recover_ratio:
|
|
69
|
+
if now - self._last_ramp >= cfg.ramp_interval_s and self.rpm < cfg.max_rpm:
|
|
70
|
+
self.rpm = min(cfg.max_rpm, self.rpm * cfg.ramp_factor)
|
|
71
|
+
self._last_ramp = now
|
|
72
|
+
reason = f"load {ratio:.1f}x baseline, ramping up"
|
|
73
|
+
if self.state is State.RECOVERING and self.rpm >= cfg.max_rpm:
|
|
74
|
+
self.state = State.NORMAL
|
|
75
|
+
reason = reason or "fully recovered"
|
|
76
|
+
|
|
77
|
+
if self.state is old_state and self.rpm == old_rpm:
|
|
78
|
+
return None
|
|
79
|
+
return Transition(old_state, self.state, old_rpm, self.rpm, reason)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Per-request cost and the budget guard."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import openai
|
|
6
|
+
from loguru import logger
|
|
7
|
+
|
|
8
|
+
from .config import CostConfig, LogConfig
|
|
9
|
+
from .records import RequestRecord
|
|
10
|
+
from .sources import open_source
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BudgetExceeded(openai.OpenAIError):
|
|
14
|
+
"""Raised instead of sending a request once ``CostConfig.budget_usd`` has been spent."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, spent_usd: float, budget_usd: float) -> None:
|
|
17
|
+
super().__init__(f"budget of ${budget_usd:.4f} exhausted (${spent_usd:.4f} spent)")
|
|
18
|
+
self.spent_usd = spent_usd
|
|
19
|
+
self.budget_usd = budget_usd
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CostTracker:
|
|
23
|
+
"""Adds up the cost of the requests of one client. Available as ``client.cost``."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, config: CostConfig, log: LogConfig | None = None) -> None:
|
|
26
|
+
self.config = config
|
|
27
|
+
self.logged_usd = 0.0 # spent before this client was created, according to the log
|
|
28
|
+
if config.include_logged:
|
|
29
|
+
source = open_source(log)
|
|
30
|
+
if source is not None:
|
|
31
|
+
try:
|
|
32
|
+
self.logged_usd = source.total_cost()
|
|
33
|
+
finally:
|
|
34
|
+
source.close()
|
|
35
|
+
self.session_usd = 0.0
|
|
36
|
+
self.priced = 0
|
|
37
|
+
self.unpriced = 0
|
|
38
|
+
self._warned: set[str | None] = set()
|
|
39
|
+
self._exhausted_logged = False
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def spent_usd(self) -> float:
|
|
43
|
+
return self.logged_usd + self.session_usd
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def remaining_usd(self) -> float | None:
|
|
47
|
+
if self.config.budget_usd is None:
|
|
48
|
+
return None
|
|
49
|
+
return max(0.0, self.config.budget_usd - self.spent_usd)
|
|
50
|
+
|
|
51
|
+
def check(self) -> None:
|
|
52
|
+
"""Raise :class:`BudgetExceeded` if the budget is exhausted."""
|
|
53
|
+
budget = self.config.budget_usd
|
|
54
|
+
if budget is None or self.spent_usd < budget:
|
|
55
|
+
return
|
|
56
|
+
if not self._exhausted_logged:
|
|
57
|
+
self._exhausted_logged = True
|
|
58
|
+
logger.warning(
|
|
59
|
+
"budget of ${:.4f} exhausted (${:.4f} spent): refusing new requests",
|
|
60
|
+
budget,
|
|
61
|
+
self.spent_usd,
|
|
62
|
+
)
|
|
63
|
+
raise BudgetExceeded(self.spent_usd, budget)
|
|
64
|
+
|
|
65
|
+
def add(self, record: RequestRecord) -> None:
|
|
66
|
+
"""Fill in ``record.cost_usd`` if the provider did not report it, and count it."""
|
|
67
|
+
if record.cost_usd is None:
|
|
68
|
+
price = self.config.prices.get(record.model or "")
|
|
69
|
+
if price is None and record.response_model:
|
|
70
|
+
price = self.config.prices.get(record.response_model)
|
|
71
|
+
if price is not None and record.prompt_tokens is not None:
|
|
72
|
+
record.cost_usd = price.cost(
|
|
73
|
+
record.prompt_tokens, record.completion_tokens, record.cached_tokens
|
|
74
|
+
)
|
|
75
|
+
if record.cost_usd is not None:
|
|
76
|
+
self.priced += 1
|
|
77
|
+
self.session_usd += record.cost_usd
|
|
78
|
+
elif record.status_code == 200:
|
|
79
|
+
self.unpriced += 1
|
|
80
|
+
if self.config.budget_usd is not None and record.model not in self._warned:
|
|
81
|
+
self._warned.add(record.model)
|
|
82
|
+
logger.warning(
|
|
83
|
+
"{}: no cost reported and no price configured; the budget does not see "
|
|
84
|
+
"these requests (set CostConfig(prices=...))",
|
|
85
|
+
record.model or record.path,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def stats(self) -> dict[str, float | int | None]:
|
|
89
|
+
return {
|
|
90
|
+
"spent_usd": round(self.spent_usd, 6),
|
|
91
|
+
"session_usd": round(self.session_usd, 6),
|
|
92
|
+
"budget_usd": self.config.budget_usd,
|
|
93
|
+
"remaining_usd": self.remaining_usd,
|
|
94
|
+
"priced": self.priced,
|
|
95
|
+
"unpriced": self.unpriced,
|
|
96
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Load estimation: turn observed request latencies into a load ratio (current / baseline)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import statistics
|
|
7
|
+
import time
|
|
8
|
+
from collections import deque
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from typing import Protocol, runtime_checkable
|
|
11
|
+
|
|
12
|
+
from .config import ThrottleConfig
|
|
13
|
+
from .records import RequestRecord
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@runtime_checkable
|
|
17
|
+
class LoadEstimator(Protocol):
|
|
18
|
+
"""Anything that can judge the endpoint's load from completed requests.
|
|
19
|
+
|
|
20
|
+
Implement this to use a different signal (e.g. queue depth from a Prometheus endpoint) and
|
|
21
|
+
pass it via ``ThrottleConfig(estimator_factory=...)``.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def observe(self, record: RequestRecord, *, throttled: bool) -> None:
|
|
25
|
+
"""Feed one successful, eligible request. ``throttled`` is True while at min RPM."""
|
|
26
|
+
|
|
27
|
+
def load_ratio(self) -> float | None:
|
|
28
|
+
"""Current load relative to an idle endpoint (1.0 = idle); None while unknown."""
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def baseline(self) -> float | None: ...
|
|
32
|
+
|
|
33
|
+
def reset(self) -> None:
|
|
34
|
+
"""Forget the learned baseline."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def metric_value(record: RequestRecord, config: ThrottleConfig) -> float | None:
|
|
38
|
+
"""Compute the latency signal of one request according to ``config.metric``."""
|
|
39
|
+
metric = config.metric
|
|
40
|
+
if callable(metric):
|
|
41
|
+
return metric(record)
|
|
42
|
+
if metric == "latency":
|
|
43
|
+
return record.latency_s
|
|
44
|
+
if metric == "ttfb":
|
|
45
|
+
return record.ttfb_s
|
|
46
|
+
# latency_per_token: streamed requests expose the queueing delay directly as TTFB.
|
|
47
|
+
if record.stream and record.ttfb_s is not None:
|
|
48
|
+
return record.ttfb_s
|
|
49
|
+
if record.latency_s is None:
|
|
50
|
+
return None
|
|
51
|
+
tokens = record.completion_tokens or 0
|
|
52
|
+
return record.latency_s / max(tokens, config.min_tokens)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _percentile(values: list[float], pct: float) -> float:
|
|
56
|
+
ordered = sorted(values)
|
|
57
|
+
k = (len(ordered) - 1) * pct / 100
|
|
58
|
+
lo, hi = math.floor(k), math.ceil(k)
|
|
59
|
+
return ordered[lo] + (ordered[hi] - ordered[lo]) * (k - lo)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class LatencyEstimator:
|
|
63
|
+
"""Compare the median of the last ``window`` observations to a low-percentile baseline.
|
|
64
|
+
|
|
65
|
+
The baseline is the ``baseline_percentile``-th percentile of observations made within
|
|
66
|
+
``baseline_window_s``. Observations made while throttled are not added to the baseline, so a
|
|
67
|
+
long busy period cannot turn into the new "normal". The window is measured relative to the
|
|
68
|
+
newest baseline sample, which means the baseline freezes (rather than expires) meanwhile.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(self, config: ThrottleConfig, clock: Callable[[], float] = time.monotonic) -> None:
|
|
72
|
+
self.config = config
|
|
73
|
+
self._clock = clock
|
|
74
|
+
self._recent: deque[float] = deque(maxlen=config.window)
|
|
75
|
+
self._samples: deque[tuple[float, float]] = deque(maxlen=5000)
|
|
76
|
+
self._pinned: float | None = config.baseline
|
|
77
|
+
|
|
78
|
+
def observe(self, record: RequestRecord, *, throttled: bool) -> None:
|
|
79
|
+
value = metric_value(record, self.config)
|
|
80
|
+
if value is None or value <= 0:
|
|
81
|
+
return
|
|
82
|
+
self._recent.append(value)
|
|
83
|
+
if not throttled:
|
|
84
|
+
now = self._clock()
|
|
85
|
+
self._samples.append((now, value))
|
|
86
|
+
while self._samples and self._samples[0][0] < now - self.config.baseline_window_s:
|
|
87
|
+
self._samples.popleft()
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def baseline(self) -> float | None:
|
|
91
|
+
if self._pinned is not None:
|
|
92
|
+
return self._pinned
|
|
93
|
+
if len(self._samples) < self.config.warmup_requests:
|
|
94
|
+
return None
|
|
95
|
+
return _percentile([v for _, v in self._samples], self.config.baseline_percentile)
|
|
96
|
+
|
|
97
|
+
def current(self) -> float | None:
|
|
98
|
+
return statistics.median(self._recent) if self._recent else None
|
|
99
|
+
|
|
100
|
+
def load_ratio(self) -> float | None:
|
|
101
|
+
baseline, current = self.baseline, self.current()
|
|
102
|
+
if baseline is None or current is None or baseline <= 0:
|
|
103
|
+
return None
|
|
104
|
+
return current / baseline
|
|
105
|
+
|
|
106
|
+
def pin_baseline(self, value: float | None) -> None:
|
|
107
|
+
"""Use ``value`` as the baseline (None returns to estimating it)."""
|
|
108
|
+
self._pinned = value
|
|
109
|
+
|
|
110
|
+
def reset(self) -> None:
|
|
111
|
+
self._pinned = self.config.baseline
|
|
112
|
+
self._samples.clear()
|
|
113
|
+
self._recent.clear()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from .batch import calibrate, run_batch, run_batch_sync
|
|
2
|
+
from .messages import cached_system_message
|
|
3
|
+
from .params import reproducible
|
|
4
|
+
from .structured import (
|
|
5
|
+
StructuredOutputError,
|
|
6
|
+
extract_json,
|
|
7
|
+
response_format_from_model,
|
|
8
|
+
structured,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"StructuredOutputError",
|
|
13
|
+
"cached_system_message",
|
|
14
|
+
"calibrate",
|
|
15
|
+
"extract_json",
|
|
16
|
+
"reproducible",
|
|
17
|
+
"response_format_from_model",
|
|
18
|
+
"run_batch",
|
|
19
|
+
"run_batch_sync",
|
|
20
|
+
"structured",
|
|
21
|
+
]
|