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.
@@ -0,0 +1,207 @@
1
+ """Read request records back from the log while the client runs (cache lookups, spent cost)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import json
7
+ import os
8
+ import sqlite3
9
+ from pathlib import Path
10
+ from typing import IO, Any, Protocol
11
+
12
+ from .logfiles import compression_of, open_binary, rotated_segments
13
+
14
+
15
+ class _Target(Protocol):
16
+ @property
17
+ def sqlite(self) -> str | Path | None: ...
18
+ @property
19
+ def jsonl(self) -> str | Path | None: ...
20
+
21
+
22
+ def _cacheable(row: dict[str, Any]) -> bool:
23
+ return (
24
+ bool(row.get("cache_key"))
25
+ and row.get("status_code") == 200
26
+ and not row.get("error")
27
+ and not row.get("stream")
28
+ and isinstance(row.get("response_body"), dict)
29
+ )
30
+
31
+
32
+ class SQLiteSource:
33
+ """Looks up responses in the ``requests`` table written by the SQLite log sink."""
34
+
35
+ _QUERY = (
36
+ "SELECT response_body FROM requests WHERE cache_key = ? AND status_code = 200 "
37
+ "AND error IS NULL AND NOT stream AND response_body IS NOT NULL ORDER BY rowid LIMIT 1"
38
+ )
39
+ _COST = "SELECT SUM(cost_usd) FROM requests WHERE NOT cache_hit"
40
+
41
+ def __init__(self, path: str | Path) -> None:
42
+ self.path = Path(path)
43
+ self._conn: sqlite3.Connection | None = None
44
+
45
+ def _query(self, sql: str, *args: Any) -> Any:
46
+ if self._conn is None:
47
+ if not self.path.exists(): # the log sink creates it with the first record
48
+ return None
49
+ self._conn = sqlite3.connect(self.path, check_same_thread=False)
50
+ try:
51
+ return self._conn.execute(sql, args).fetchone()
52
+ except sqlite3.OperationalError: # table or column not created yet
53
+ return None
54
+
55
+ def get(self, key: str) -> dict[str, Any] | None:
56
+ row = self._query(self._QUERY, key)
57
+ if row is None:
58
+ return None
59
+ with contextlib.suppress(ValueError):
60
+ body = json.loads(row[0])
61
+ if isinstance(body, dict):
62
+ return body
63
+ return None
64
+
65
+ def total_cost(self) -> float:
66
+ """Sum of ``cost_usd`` over all records that were not cache hits."""
67
+ row = self._query(self._COST)
68
+ return float(row[0]) if row is not None and row[0] is not None else 0.0
69
+
70
+ def close(self) -> None:
71
+ if self._conn is not None:
72
+ self._conn.close()
73
+ self._conn = None
74
+
75
+
76
+ class _Rotated(Exception):
77
+ """The active file was replaced since it was last indexed."""
78
+
79
+
80
+ class JSONLSource:
81
+ """Indexes a JSONL log by cache key and reads matching lines on demand.
82
+
83
+ The segments loguru rotated away from the log are indexed too. Responses from compressed
84
+ segments are kept in memory (only the first one per cache key); everything else is read back
85
+ by byte offset. New lines in the active file are indexed incrementally on each lookup, and a
86
+ rotation while the client runs triggers a full re-index.
87
+ """
88
+
89
+ def __init__(self, path: str | Path) -> None:
90
+ self.path = Path(path)
91
+ self._live = compression_of(self.path) is None # a compressed file does not grow
92
+ self._reset()
93
+
94
+ def _reset(self) -> None:
95
+ # cache key -> (plain file, byte offset of the first matching line) or response JSON
96
+ self._index: dict[str, tuple[Path, int] | str] = {}
97
+ self._cost = 0.0 # cost_usd of the indexed records that were not cache hits
98
+ self._loaded = False # rotated or compressed segments indexed
99
+ self._file_id: tuple[int, int] | None = None # (device, inode) of the active file
100
+ self._offset = 0 # bytes of the active file indexed so far
101
+
102
+ def get(self, key: str) -> dict[str, Any] | None:
103
+ if key not in self._index:
104
+ self._scan()
105
+ for _ in range(2):
106
+ entry = self._index.get(key)
107
+ if entry is None:
108
+ return None
109
+ body = self._read(key, entry)
110
+ if body is not None:
111
+ return body
112
+ # a rotation renamed, compressed or replaced the file since it was indexed
113
+ self._reset()
114
+ self._scan()
115
+ return None
116
+
117
+ @staticmethod
118
+ def _read(key: str, entry: tuple[Path, int] | str) -> dict[str, Any] | None:
119
+ if isinstance(entry, str):
120
+ body: dict[str, Any] = json.loads(entry)
121
+ return body
122
+ path, pos = entry
123
+ try:
124
+ with path.open("rb") as fh:
125
+ fh.seek(pos)
126
+ row = json.loads(fh.readline())
127
+ except (FileNotFoundError, ValueError):
128
+ return None
129
+ if not isinstance(row, dict) or row.get("cache_key") != key:
130
+ return None
131
+ body = row["response_body"]
132
+ return body
133
+
134
+ def _scan(self) -> None:
135
+ for _ in range(5):
136
+ try:
137
+ if not self._loaded:
138
+ self._load_segments()
139
+ if self._live:
140
+ self._scan_active()
141
+ return
142
+ except (FileNotFoundError, _Rotated): # loguru renamed or compressed a file under us
143
+ self._reset()
144
+
145
+ def _load_segments(self) -> None:
146
+ segments = rotated_segments(self.path) if self._live else [self.path]
147
+ for segment in segments:
148
+ with open_binary(segment) as fh:
149
+ self._index_lines(fh, segment, 0, in_memory=compression_of(segment) is not None)
150
+ self._loaded = True
151
+ if self._live:
152
+ self._scan_active()
153
+ if rotated_segments(self.path) != segments: # rotated while we were reading
154
+ raise _Rotated
155
+
156
+ def _scan_active(self) -> None:
157
+ try:
158
+ fh = self.path.open("rb")
159
+ except FileNotFoundError: # nothing written yet, or in the middle of a rotation
160
+ return
161
+ with fh:
162
+ stat = os.fstat(fh.fileno())
163
+ file_id = (stat.st_dev, stat.st_ino)
164
+ if self._file_id is None:
165
+ self._file_id = file_id
166
+ elif file_id != self._file_id or stat.st_size < self._offset:
167
+ raise _Rotated
168
+ if stat.st_size > self._offset:
169
+ fh.seek(self._offset)
170
+ self._offset = self._index_lines(fh, self.path, self._offset, in_memory=False)
171
+
172
+ def _index_lines(self, fh: IO[bytes], path: Path, pos: int, *, in_memory: bool) -> int:
173
+ for line in fh:
174
+ if not line.endswith(b"\n"): # still being written
175
+ break
176
+ with contextlib.suppress(ValueError):
177
+ row = json.loads(line)
178
+ if not isinstance(row, dict):
179
+ continue
180
+ if _cacheable(row) and row["cache_key"] not in self._index:
181
+ self._index[row["cache_key"]] = (
182
+ json.dumps(row["response_body"]) if in_memory else (path, pos)
183
+ )
184
+ if not row.get("cache_hit"):
185
+ self._cost += float(row.get("cost_usd") or 0.0)
186
+ pos += len(line)
187
+ return pos
188
+
189
+ def total_cost(self) -> float:
190
+ """Sum of ``cost_usd`` over all records that were not cache hits."""
191
+ self._scan()
192
+ return self._cost
193
+
194
+ def close(self) -> None:
195
+ pass
196
+
197
+
198
+ RecordSource = SQLiteSource | JSONLSource
199
+
200
+
201
+ def open_source(target: _Target | None) -> RecordSource | None:
202
+ """A reader for the SQLite database of ``target``, else its JSONL file, else None."""
203
+ if target is not None and target.sqlite is not None:
204
+ return SQLiteSource(target.sqlite)
205
+ if target is not None and target.jsonl is not None:
206
+ return JSONLSource(target.jsonl)
207
+ return None
@@ -0,0 +1,229 @@
1
+ """Per-model throttling state: limiter + load estimator + controller."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+ import threading
7
+ import time
8
+ from collections.abc import Callable
9
+ from dataclasses import dataclass
10
+ from typing import Any
11
+
12
+ from loguru import logger
13
+
14
+ from .config import ThrottleConfig
15
+ from .controller import State, ThrottleController, Transition
16
+ from .estimator import LatencyEstimator, LoadEstimator
17
+ from .limiter import AdaptiveLimiter
18
+ from .records import RequestRecord
19
+
20
+
21
+ @dataclass
22
+ class LaneStats:
23
+ requests: int = 0
24
+ errors: int = 0
25
+ prompt_tokens: int = 0
26
+ completion_tokens: int = 0
27
+ cost_usd: float = 0.0
28
+
29
+ def add(self, record: RequestRecord) -> None:
30
+ self.requests += 1
31
+ if record.error or (record.status_code or 0) >= 400:
32
+ self.errors += 1
33
+ self.prompt_tokens += record.prompt_tokens or 0
34
+ self.completion_tokens += record.completion_tokens or 0
35
+ self.cost_usd += record.cost_usd or 0.0
36
+
37
+
38
+ class Lane:
39
+ """Throttling state for one (endpoint, model) pair.
40
+
41
+ Endpoints usually serve every model from its own backend, so load is tracked per model.
42
+ Lanes for endpoints that are not in ``observe_paths`` are paced and react to server errors,
43
+ but they do not adapt to latency.
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ name: str,
49
+ config: ThrottleConfig,
50
+ *,
51
+ observed: bool,
52
+ clock: Callable[[], float] = time.monotonic,
53
+ rng: random.Random | None = None,
54
+ ) -> None:
55
+ self.name = name
56
+ self.config = config
57
+ self.observed = observed
58
+ self.limiter = AdaptiveLimiter(config.initial_rpm, config.max_concurrency)
59
+ self.estimator: LoadEstimator = (
60
+ config.estimator_factory(config)
61
+ if config.estimator_factory is not None
62
+ else LatencyEstimator(config, clock=clock)
63
+ )
64
+ self.controller = ThrottleController(config, clock=clock)
65
+ self.totals = LaneStats()
66
+ self.since_summary = LaneStats()
67
+ self.last_record: RequestRecord | None = None
68
+ self._rng = rng or random.Random()
69
+ self._backoff_level = 0 # consecutive 429s without Retry-After (fixed mode)
70
+ self._lock = threading.Lock()
71
+
72
+ @property
73
+ def rpm(self) -> float:
74
+ return self.controller.rpm
75
+
76
+ @property
77
+ def state(self) -> State:
78
+ return self.controller.state
79
+
80
+ def observe(
81
+ self, record: RequestRecord, *, eligible: bool, retry_after: float | None = None
82
+ ) -> Transition | None:
83
+ """Update load estimate and rate after a request finished.
84
+
85
+ ``retry_after`` is the server's ``Retry-After`` in seconds, if it sent one.
86
+ """
87
+ with self._lock:
88
+ if not self.config.adaptive:
89
+ self._handle_fixed(record, retry_after)
90
+ return self._observe(record, eligible=eligible)
91
+
92
+ def _handle_fixed(self, record: RequestRecord, retry_after: float | None) -> None:
93
+ """Fixed mode: pause this lane after a 429 instead of changing the rate."""
94
+ status = record.status_code
95
+ if status is not None and status < 400:
96
+ self._backoff_level = 0
97
+ if status != 429:
98
+ return
99
+ cfg = self.config
100
+ if retry_after is not None:
101
+ delay, reason = retry_after, "Retry-After"
102
+ else:
103
+ # 429s of requests sent before the current pause began do not raise the level.
104
+ if self.limiter.paused_s == 0 or self._backoff_level == 0:
105
+ self._backoff_level += 1
106
+ exponent = min(self._backoff_level - 1, 30)
107
+ delay = min(cfg.backoff_max_s, cfg.backoff_initial_s * 2**exponent)
108
+ reason = f"backoff #{self._backoff_level}"
109
+ delay += self._rng.uniform(0, cfg.backoff_jitter_s)
110
+ self.limiter.pause(delay)
111
+ logger.warning("{}: HTTP 429, pausing {:.1f} s ({})", self.name, delay, reason)
112
+
113
+ def _observe(self, record: RequestRecord, *, eligible: bool) -> Transition | None:
114
+ self.last_record = record
115
+ self.totals.add(record)
116
+ self.since_summary.add(record)
117
+ congested = is_congestion(record)
118
+ if not self.observed:
119
+ ratio: float | None = 1.0 # non-latency lanes only react to server errors
120
+ else:
121
+ if eligible and not congested and record.error is None:
122
+ self.estimator.observe(record, throttled=self.state is State.THROTTLED)
123
+ ratio = self.estimator.load_ratio()
124
+ transition = self.controller.update(ratio, congested=congested)
125
+ if transition is not None:
126
+ self.limiter.set_rpm(self.controller.rpm)
127
+ _log_transition(self.name, transition)
128
+ return transition
129
+
130
+ def snapshot(self) -> dict[str, Any]:
131
+ with self._lock:
132
+ return self._snapshot()
133
+
134
+ def _snapshot(self) -> dict[str, Any]:
135
+ return {
136
+ "state": self.state.value,
137
+ "rpm": round(self.rpm, 2),
138
+ "baseline": self.estimator.baseline,
139
+ "load_ratio": self.estimator.load_ratio() if self.observed else None,
140
+ "in_flight": self.limiter.in_flight,
141
+ "paused_s": round(self.limiter.paused_s, 2),
142
+ "requests": self.totals.requests,
143
+ "errors": self.totals.errors,
144
+ "prompt_tokens": self.totals.prompt_tokens,
145
+ "completion_tokens": self.totals.completion_tokens,
146
+ "cost_usd": round(self.totals.cost_usd, 6),
147
+ }
148
+
149
+
150
+ def is_congestion(record: RequestRecord) -> bool:
151
+ """429, 5xx and timeouts mean the server is struggling."""
152
+ status = record.status_code
153
+ if status is not None and (status == 429 or status >= 500):
154
+ return True
155
+ return record.error is not None and "Timeout" in record.error
156
+
157
+
158
+ def _log_transition(name: str, t: Transition) -> None:
159
+ if t.new_state is State.THROTTLED and t.old_state is not State.THROTTLED:
160
+ logger.warning(
161
+ "{}: {} -> throttling {:.0f} -> {:.0f} RPM", name, t.reason, t.old_rpm, t.new_rpm
162
+ )
163
+ elif t.old_rpm != t.new_rpm:
164
+ logger.info(
165
+ "{}: {} ({:.0f} -> {:.0f} RPM, {})",
166
+ name,
167
+ t.reason,
168
+ t.old_rpm,
169
+ t.new_rpm,
170
+ t.new_state.value,
171
+ )
172
+ else:
173
+ logger.info("{}: {} ({})", name, t.reason, t.new_state.value)
174
+
175
+
176
+ class Throttle:
177
+ """All lanes of one client. Available as ``client.throttle`` on :class:`rr.AsyncOpenAI`.
178
+
179
+ To enforce one rate across several clients, e.g. one per thread with its own
180
+ ``asyncio.run``, create one ``Throttle`` and pass it as ``throttle=`` to each client.
181
+ """
182
+
183
+ def __init__(
184
+ self, config: ThrottleConfig | None = None, clock: Callable[[], float] = time.monotonic
185
+ ) -> None:
186
+ self.config = config or ThrottleConfig()
187
+ self._clock = clock
188
+ self._rng = random.Random() # seeded from OS entropy: jitter differs between clients
189
+ self._lanes: dict[tuple[str, str | None], Lane] = {}
190
+ self._lock = threading.Lock()
191
+
192
+ def lane(self, path: str, model: str | None) -> Lane:
193
+ key = (path, model)
194
+ with self._lock:
195
+ lane = self._lanes.get(key)
196
+ if lane is None:
197
+ observed = self.config.adaptive and any(
198
+ path.endswith(p) for p in self.config.observe_paths
199
+ )
200
+ name = model or path
201
+ lane = Lane(name, self.config, observed=observed, clock=self._clock, rng=self._rng)
202
+ self._lanes[key] = lane
203
+ return lane
204
+
205
+ def lanes(self) -> list[Lane]:
206
+ with self._lock:
207
+ return list(self._lanes.values())
208
+
209
+ def find(self, model: str) -> list[Lane]:
210
+ with self._lock:
211
+ return [lane for (_, m), lane in self._lanes.items() if m == model]
212
+
213
+ def stats(self) -> dict[str, dict[str, Any]]:
214
+ """Current state, rate, load and totals per lane (keyed by model, or path)."""
215
+ with self._lock:
216
+ items = list(self._lanes.items())
217
+ out: dict[str, dict[str, Any]] = {}
218
+ for (path, model), lane in items:
219
+ key = model if model is not None else path
220
+ if key in out:
221
+ key = f"{model} {path}"
222
+ out[key] = lane.snapshot()
223
+ return out
224
+
225
+ def reset_baseline(self, model: str | None = None) -> None:
226
+ """Forget learned baselines (of one model, or all)."""
227
+ for lane in self.lanes():
228
+ if model is None or lane.name == model:
229
+ lane.estimator.reset()