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,127 @@
1
+ """Running many requests and calibrating the latency baseline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import statistics
7
+ import uuid
8
+ from collections.abc import Awaitable, Callable, Iterable, Sequence
9
+ from typing import Any
10
+
11
+ import openai
12
+
13
+ from ..client import get_throttle
14
+ from ..config import ThrottleConfig
15
+ from ..cost import BudgetExceeded
16
+ from ..estimator import LatencyEstimator, metric_value
17
+ from ..records import tags
18
+
19
+ Call = Callable[..., Awaitable[Any]]
20
+
21
+
22
+ async def run_batch(
23
+ client: openai.AsyncOpenAI,
24
+ requests: Iterable[dict[str, Any]],
25
+ *,
26
+ call: Call | None = None,
27
+ progress: bool = False,
28
+ return_exceptions: bool = True,
29
+ batch_id: str | None = None,
30
+ ) -> list[Any]:
31
+ """Run many requests concurrently and return their results in input order.
32
+
33
+ The throttle paces the requests, so all of them can be submitted at once. Each request
34
+ record is tagged with ``batch_id`` and ``item_index`` for joining logs with results.
35
+ Once a cost budget is exhausted, the remaining requests fail fast with
36
+ :class:`responsible_request.BudgetExceeded`.
37
+
38
+ :param requests: Keyword arguments for ``call``, one dict per request.
39
+ :param call: Coroutine function to call, defaults to ``client.chat.completions.create``.
40
+ :param progress: Show a tqdm progress bar (requires the ``progress`` extra).
41
+ :param return_exceptions: Put exceptions into the result list instead of raising the first.
42
+ :param batch_id: Tag value, a random id by default.
43
+ """
44
+ call = call or client.chat.completions.create
45
+ items: Sequence[dict[str, Any]] = list(requests)
46
+ batch_id = batch_id or uuid.uuid4().hex[:12]
47
+ bar = _progress_bar(len(items)) if progress else None
48
+
49
+ async def one(index: int, kwargs: dict[str, Any]) -> Any:
50
+ try:
51
+ with tags(batch_id=batch_id, item_index=index):
52
+ return await call(**kwargs)
53
+ except openai.APIConnectionError as exc:
54
+ # older SDKs wrap exceptions raised by the transport
55
+ if isinstance(exc.__cause__, BudgetExceeded):
56
+ raise exc.__cause__ from None
57
+ raise
58
+ finally:
59
+ if bar is not None:
60
+ bar.update(1)
61
+
62
+ try:
63
+ return await asyncio.gather(
64
+ *(one(i, kw) for i, kw in enumerate(items)), return_exceptions=return_exceptions
65
+ )
66
+ finally:
67
+ if bar is not None:
68
+ bar.close()
69
+
70
+
71
+ def run_batch_sync(
72
+ client: openai.AsyncOpenAI, requests: Iterable[dict[str, Any]], **kwargs: Any
73
+ ) -> list[Any]:
74
+ """Blocking version of :func:`run_batch` for scripts (runs its own event loop)."""
75
+
76
+ async def main() -> list[Any]:
77
+ try:
78
+ return await run_batch(client, requests, **kwargs)
79
+ finally:
80
+ await client.close()
81
+
82
+ return asyncio.run(main())
83
+
84
+
85
+ def _progress_bar(total: int) -> Any:
86
+ try:
87
+ from tqdm.auto import tqdm
88
+ except ImportError as exc: # pragma: no cover
89
+ raise ImportError(
90
+ "progress=True requires tqdm: pip install responsible-request[progress]"
91
+ ) from exc
92
+ return tqdm(total=total)
93
+
94
+
95
+ async def calibrate(
96
+ client: openai.AsyncOpenAI,
97
+ model: str,
98
+ *,
99
+ n: int = 5,
100
+ messages: list[Any] | None = None,
101
+ **kwargs: Any,
102
+ ) -> float:
103
+ """Measure the latency baseline of ``model`` with ``n`` sequential requests and pin it.
104
+
105
+ Run this while the endpoint is idle (e.g. at night) or use a representative request of your
106
+ experiment via ``messages``/``kwargs`` so that the baseline matches your workload. Returns
107
+ the pinned baseline (in units of the configured metric).
108
+ """
109
+ throttle = get_throttle(client)
110
+ config: ThrottleConfig = throttle.config
111
+ messages = messages or [{"role": "user", "content": "Reply with the single word: OK"}]
112
+ values: list[float] = []
113
+ for _ in range(n):
114
+ with tags(calibration=True):
115
+ await client.chat.completions.create(model=model, messages=messages, **kwargs)
116
+ # Use the transport's own measurement, which excludes time spent in the rate limiter.
117
+ records = [lane.last_record for lane in throttle.find(model) if lane.last_record]
118
+ value = metric_value(records[-1], config) if records else None
119
+ if value is not None:
120
+ values.append(value)
121
+ if not values:
122
+ raise RuntimeError("calibration produced no usable measurements")
123
+ baseline = statistics.median(values)
124
+ for lane in throttle.find(model):
125
+ if isinstance(lane.estimator, LatencyEstimator):
126
+ lane.estimator.pin_baseline(baseline)
127
+ return baseline
@@ -0,0 +1,18 @@
1
+ """Message builders."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ def cached_system_message(content: str) -> dict[str, Any]:
9
+ """A system message marked for OpenRouter's prompt cache (``cache_control: ephemeral``).
10
+
11
+ The provider caches the prompt prefix up to this message, so repeated requests with the same
12
+ system prompt pay less for input tokens; the model still runs. This is unrelated to the
13
+ response cache (``cache=True``), which replays stored responses without sending a request.
14
+ """
15
+ return {
16
+ "role": "system",
17
+ "content": [{"type": "text", "text": content, "cache_control": {"type": "ephemeral"}}],
18
+ }
@@ -0,0 +1,18 @@
1
+ """Sampling-parameter presets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ def reproducible(seed: int = 0, **overrides: Any) -> dict[str, Any]:
9
+ """Parameters that make chat completions as repeatable as the backend allows.
10
+
11
+ Greedy decoding (``temperature=0``, ``top_p=1``) plus a fixed ``seed``. Spread it into a call
12
+ (``create(..., **rr.reproducible())``) or set it for every request with
13
+ ``rr.AsyncOpenAI(default_params=rr.reproducible())``.
14
+
15
+ Note: outputs can still differ between runs, because batched GPU inference is not bit-exact
16
+ (the batch composition depends on the other requests the server is processing).
17
+ """
18
+ return {"temperature": 0.0, "top_p": 1.0, "seed": seed, **overrides}
@@ -0,0 +1,150 @@
1
+ """Structured output: request JSON matching a Pydantic model and validate the answer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import json
7
+ import re
8
+ from collections.abc import Iterable
9
+ from typing import Any, TypeVar
10
+
11
+ import openai
12
+ from pydantic import BaseModel, ValidationError
13
+
14
+ T = TypeVar("T", bound=BaseModel)
15
+
16
+
17
+ class StructuredOutputError(Exception):
18
+ """The model did not produce valid output within the allowed number of attempts."""
19
+
20
+ def __init__(self, message: str, *, last_content: str | None, errors: list[Exception]) -> None:
21
+ super().__init__(message)
22
+ self.last_content = last_content
23
+ self.errors = errors
24
+
25
+
26
+ def _strictify(node: Any) -> None:
27
+ if isinstance(node, dict):
28
+ if node.get("type") == "object" and "properties" in node:
29
+ node["additionalProperties"] = False
30
+ node["required"] = list(node["properties"])
31
+ node.pop("default", None)
32
+ for value in node.values():
33
+ _strictify(value)
34
+ elif isinstance(node, list):
35
+ for item in node:
36
+ _strictify(item)
37
+
38
+
39
+ def response_format_from_model(model: type[BaseModel], *, strict: bool = True) -> dict[str, Any]:
40
+ """Build a ``json_schema`` ``response_format`` from a Pydantic model.
41
+
42
+ With ``strict=True`` every object forbids additional properties and marks all properties as
43
+ required, as strict structured-output backends demand.
44
+ """
45
+ schema = copy.deepcopy(model.model_json_schema())
46
+ if strict:
47
+ _strictify(schema)
48
+ return {
49
+ "type": "json_schema",
50
+ "json_schema": {"name": model.__name__, "schema": schema, "strict": strict},
51
+ }
52
+
53
+
54
+ _FENCE = re.compile(r"^\s*```(?:json)?\s*(.*?)\s*```\s*$", re.DOTALL)
55
+
56
+
57
+ def extract_json(text: str) -> str:
58
+ """Strip Markdown code fences and surrounding prose around a JSON object."""
59
+ match = _FENCE.match(text)
60
+ if match:
61
+ return match.group(1)
62
+ start, end = text.find("{"), text.rfind("}")
63
+ if start != -1 and end > start and not text.lstrip().startswith("{"):
64
+ return text[start : end + 1]
65
+ return text
66
+
67
+
68
+ def _answer_text(message: Any) -> str:
69
+ """The answer text: ``content``, else ``reasoning``, else ``reasoning_content`` (some
70
+ reasoning models put the JSON there and leave ``content`` empty)."""
71
+ for key in ("content", "reasoning", "reasoning_content"):
72
+ value = getattr(message, key, None) # the SDK's models keep unknown fields as attributes
73
+ if isinstance(value, str) and value.strip():
74
+ return value
75
+ return ""
76
+
77
+
78
+ async def structured(
79
+ client: openai.AsyncOpenAI,
80
+ *,
81
+ model: str,
82
+ messages: Iterable[Any],
83
+ schema: type[T],
84
+ retries: int = 2,
85
+ strict: bool = True,
86
+ fallback: bool = True,
87
+ **kwargs: Any,
88
+ ) -> T:
89
+ """Ask for JSON matching ``schema`` and return it as a validated ``schema`` instance.
90
+
91
+ If the answer does not validate, the model is shown the validation error and asked again
92
+ (up to ``retries`` times). If the backend rejects ``json_schema`` response formats and
93
+ ``fallback`` is set, it switches to ``json_object`` mode with the schema in the prompt.
94
+ If the message ``content`` is empty, the answer is read from ``reasoning``, then from
95
+ ``reasoning_content`` (some reasoning models put the JSON there).
96
+
97
+ Extra keyword arguments are passed to ``client.chat.completions.create``.
98
+ """
99
+ msgs: list[Any] = list(messages)
100
+ response_format: dict[str, Any] = response_format_from_model(schema, strict=strict)
101
+ errors: list[Exception] = []
102
+ content: str | None = None
103
+
104
+ attempt = 0
105
+ while attempt <= retries:
106
+ try:
107
+ params: dict[str, Any] = {
108
+ "model": model,
109
+ "messages": msgs,
110
+ "response_format": response_format,
111
+ **kwargs,
112
+ }
113
+ response = await client.chat.completions.create(**params)
114
+ except openai.BadRequestError as exc:
115
+ if not fallback or response_format["type"] != "json_schema":
116
+ raise
117
+ errors.append(exc)
118
+ schema_text = json.dumps(response_format["json_schema"]["schema"])
119
+ msgs = [
120
+ {
121
+ "role": "system",
122
+ "content": f"Respond only with a JSON object matching this schema:\n"
123
+ f"{schema_text}",
124
+ },
125
+ *msgs,
126
+ ]
127
+ response_format = {"type": "json_object"}
128
+ continue # the fallback does not count as a retry
129
+
130
+ content = _answer_text(response.choices[0].message)
131
+ try:
132
+ return schema.model_validate_json(extract_json(content))
133
+ except ValidationError as exc:
134
+ errors.append(exc)
135
+ msgs = [
136
+ *msgs,
137
+ {"role": "assistant", "content": content},
138
+ {
139
+ "role": "user",
140
+ "content": "Your answer did not match the required JSON schema:\n"
141
+ f"{exc}\nRespond again with only the corrected JSON object.",
142
+ },
143
+ ]
144
+ attempt += 1
145
+
146
+ raise StructuredOutputError(
147
+ f"no valid {schema.__name__} after {retries + 1} attempts",
148
+ last_content=content,
149
+ errors=errors,
150
+ )
@@ -0,0 +1,165 @@
1
+ """Evenly spaced rate limiting whose rate can change at runtime, plus a concurrency cap.
2
+
3
+ The limiter is safe to share between threads that each run their own event loop (e.g. several
4
+ threads calling ``asyncio.run``): its state is guarded by a ``threading.Lock``, and every waiter
5
+ sleeps in its own loop and is woken through ``call_soon_threadsafe``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import threading
12
+ import time
13
+ from collections import deque
14
+
15
+
16
+ class _Waiter:
17
+ """A future in the caller's event loop that another thread can resolve."""
18
+
19
+ __slots__ = ("future", "loop")
20
+
21
+ def __init__(self) -> None:
22
+ self.loop = asyncio.get_running_loop()
23
+ self.future: asyncio.Future[None] = self.loop.create_future()
24
+
25
+ def renew(self) -> None:
26
+ self.future = self.loop.create_future()
27
+
28
+ def wake(self) -> bool:
29
+ """Resolve the current future. Returns False if the waiter's loop is closed."""
30
+ try:
31
+ self.loop.call_soon_threadsafe(_resolve, self.future)
32
+ except RuntimeError:
33
+ return False
34
+ return True
35
+
36
+
37
+ def _resolve(future: asyncio.Future[None]) -> None:
38
+ if not future.done():
39
+ future.set_result(None)
40
+
41
+
42
+ class AdaptiveLimiter:
43
+ """Paces acquisitions at ``rpm`` and caps the number of concurrent holders.
44
+
45
+ Use as ``async with limiter: ...``. The concurrency slot is held for the duration of the
46
+ block. Requests leave the limiter at least ``60 / rpm`` seconds apart (no bursts), in the
47
+ order in which they got a concurrency slot. ``set_rpm`` applies to requests that are already
48
+ waiting, and ``pause`` holds back all requests for a while.
49
+ """
50
+
51
+ def __init__(self, rpm: float, max_concurrency: int) -> None:
52
+ self._lock = threading.Lock()
53
+ self._rpm = rpm
54
+ self._interval = 60 / rpm
55
+ self.max_concurrency = max_concurrency
56
+ self.in_flight = 0
57
+ self._slots_held = 0
58
+ self._last_sent = -float("inf")
59
+ self._paused_until = -float("inf")
60
+ self._slot_waiters: deque[_Waiter] = deque()
61
+ self._rate_waiters: deque[_Waiter] = deque()
62
+
63
+ @property
64
+ def rpm(self) -> float:
65
+ return self._rpm
66
+
67
+ @property
68
+ def paused_s(self) -> float:
69
+ """Remaining time of the current pause (0 if not paused)."""
70
+ return max(0.0, self._paused_until - time.monotonic())
71
+
72
+ def set_rpm(self, rpm: float) -> None:
73
+ with self._lock:
74
+ self._rpm = rpm
75
+ self._interval = 60 / rpm
76
+ self._wake_rate_head() # it re-computes its wake-up time for the new rate
77
+
78
+ def pause(self, seconds: float) -> None:
79
+ """Send nothing for ``seconds`` (extends, never shortens, a running pause)."""
80
+ with self._lock:
81
+ self._paused_until = max(self._paused_until, time.monotonic() + seconds)
82
+
83
+ async def acquire(self) -> None:
84
+ # Concurrency first, then rate: requests leave the limiter evenly spaced.
85
+ await self._acquire_slot()
86
+ try:
87
+ await self._acquire_rate()
88
+ except BaseException:
89
+ self._release_slot()
90
+ raise
91
+
92
+ def release(self) -> None:
93
+ with self._lock:
94
+ self.in_flight -= 1
95
+ self._release_slot()
96
+
97
+ async def __aenter__(self) -> None:
98
+ await self.acquire()
99
+
100
+ async def __aexit__(self, *exc: object) -> None:
101
+ self.release()
102
+
103
+ # ------------------------------------------------------------------ internals
104
+
105
+ async def _acquire_slot(self) -> None:
106
+ with self._lock:
107
+ if self._slots_held < self.max_concurrency and not self._slot_waiters:
108
+ self._slots_held += 1
109
+ return
110
+ waiter = _Waiter()
111
+ self._slot_waiters.append(waiter)
112
+ try:
113
+ await waiter.future # resolved by _release_slot, which hands over its slot
114
+ except BaseException:
115
+ with self._lock:
116
+ granted = waiter not in self._slot_waiters
117
+ if not granted:
118
+ self._slot_waiters.remove(waiter)
119
+ if granted: # the slot was already handed to us: pass it on
120
+ self._release_slot()
121
+ raise
122
+
123
+ def _release_slot(self) -> None:
124
+ with self._lock:
125
+ while self._slot_waiters:
126
+ if self._slot_waiters.popleft().wake():
127
+ return # the slot moves to the woken waiter
128
+ self._slots_held -= 1
129
+
130
+ async def _acquire_rate(self) -> None:
131
+ waiter = _Waiter()
132
+ with self._lock:
133
+ self._rate_waiters.append(waiter)
134
+ try:
135
+ while True:
136
+ with self._lock:
137
+ waiter.renew() # wake-ups from now on resolve the new future
138
+ timeout: float | None = None
139
+ if self._rate_waiters[0] is waiter:
140
+ now = time.monotonic()
141
+ ready = max(self._last_sent + self._interval, self._paused_until)
142
+ if now >= ready:
143
+ self._last_sent = now
144
+ self._rate_waiters.popleft()
145
+ self._wake_rate_head()
146
+ self.in_flight += 1
147
+ return
148
+ timeout = ready - now
149
+ # Sleep in this thread's own loop until the slot is due or we are woken.
150
+ await asyncio.wait({waiter.future}, timeout=timeout)
151
+ except BaseException:
152
+ with self._lock:
153
+ if waiter in self._rate_waiters:
154
+ was_head = self._rate_waiters[0] is waiter
155
+ self._rate_waiters.remove(waiter)
156
+ if was_head:
157
+ self._wake_rate_head()
158
+ raise
159
+
160
+ def _wake_rate_head(self) -> None:
161
+ """Wake the first waiter for the rate slot (call with the lock held)."""
162
+ while self._rate_waiters:
163
+ if self._rate_waiters[0].wake():
164
+ return
165
+ self._rate_waiters.popleft() # its loop is gone
@@ -0,0 +1,120 @@
1
+ """The files of a JSONL log: the active file plus the segments loguru rotated away from it,
2
+ optionally compressed."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import bz2
7
+ import gzip
8
+ import io
9
+ import lzma
10
+ import os
11
+ import re
12
+ import shutil
13
+ from collections.abc import Callable
14
+ from datetime import datetime
15
+ from pathlib import Path
16
+ from typing import IO, Any
17
+
18
+ COMPRESSIONS = ("gz", "bz2", "xz", "zst")
19
+ """Values of ``LogConfig.compression``: formats that are read back as a single JSONL stream."""
20
+
21
+ # loguru names a rotated segment ``{stem}.{%Y-%m-%d_%H-%M-%S_%f}[.{n}]{suffix}``
22
+ _DATE = r"\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}_\d{6}(?:\.\d+)?"
23
+
24
+
25
+ def _zstd() -> Any:
26
+ try:
27
+ from compression import zstd # Python 3.14+
28
+
29
+ return zstd
30
+ except ImportError:
31
+ pass
32
+ try:
33
+ import zstandard
34
+
35
+ return zstandard
36
+ except ImportError:
37
+ raise ImportError(
38
+ "zstd needs Python 3.14+ or: pip install responsible-request[zstd]"
39
+ ) from None
40
+
41
+
42
+ def compression_of(path: Path) -> str | None:
43
+ """The compression format of ``path`` from its extension, or None for a plain file."""
44
+ ext = path.suffix.lstrip(".")
45
+ return ext if ext in COMPRESSIONS else None
46
+
47
+
48
+ def strip_compression(path: Path) -> Path:
49
+ """``requests.jsonl.gz`` -> ``requests.jsonl``."""
50
+ return path.with_suffix("") if compression_of(path) else path
51
+
52
+
53
+ def open_binary(path: Path) -> IO[bytes]:
54
+ """Open a plain or compressed log file for reading (decompressed bytes)."""
55
+ fmt = compression_of(path)
56
+ if fmt == "zst": # zstandard's reader can't iterate over lines, a buffer on top of it can
57
+ return io.BufferedReader(_zstd().open(path, "rb"))
58
+ opener: Any = {"gz": gzip.open, "bz2": bz2.open, "xz": lzma.open}.get(fmt or "")
59
+ stream: IO[bytes] = opener(path, "rb") if opener else path.open("rb")
60
+ return stream
61
+
62
+
63
+ def rotated_segments(path: Path) -> list[Path]:
64
+ """Segments rotated away from the log at ``path`` (and compressed copies of ``path`` itself,
65
+ which loguru writes when a sink without rotation is removed), oldest first."""
66
+ pattern = re.compile(
67
+ rf"{re.escape(path.stem)}(?:\.{_DATE})?{re.escape(path.suffix)}"
68
+ rf"(?:\.(?:{'|'.join(COMPRESSIONS)}))?"
69
+ )
70
+ try:
71
+ names = {p.name for p in path.parent.iterdir()}
72
+ except FileNotFoundError:
73
+ return []
74
+ found = []
75
+ for name in names:
76
+ if name == path.name or not pattern.fullmatch(name):
77
+ continue
78
+ plain = name.rsplit(".", 1)[0]
79
+ if compression_of(Path(name)) and plain != path.name and plain in names:
80
+ continue # still being compressed: loguru removes the plain file once it is done
81
+ found.append(path.parent / name)
82
+ return sorted(found, key=lambda p: (_mtime(p), p.name))
83
+
84
+
85
+ def _mtime(path: Path) -> float:
86
+ try:
87
+ return path.stat().st_mtime
88
+ except FileNotFoundError:
89
+ return 0.0
90
+
91
+
92
+ def log_segments(path: Path) -> list[Path]:
93
+ """All files of the log at ``path``, oldest first. A compressed ``path`` is read on its own."""
94
+ if compression_of(path):
95
+ return [path]
96
+ return rotated_segments(path) + ([path] if path.exists() else [])
97
+
98
+
99
+ def _compress_zstd(path_in: str) -> None:
100
+ """Compress a finished segment to ``{path_in}.zst``, the way loguru does for other formats."""
101
+ path_out = Path(f"{path_in}.zst")
102
+ if path_out.exists(): # keep the earlier archive under a dated name
103
+ root, ext = os.path.splitext(path_in)
104
+ date = datetime.fromtimestamp(path_out.stat().st_mtime).strftime("%Y-%m-%d_%H-%M-%S_%f")
105
+ renamed, n = Path(f"{root}.{date}{ext}.zst"), 1
106
+ while renamed.exists():
107
+ n += 1
108
+ renamed = Path(f"{root}.{date}.{n}{ext}.zst")
109
+ path_out.rename(renamed)
110
+ with open(path_in, "rb") as src, _zstd().open(path_out, "wb") as dst:
111
+ shutil.copyfileobj(src, dst)
112
+ os.remove(path_in)
113
+
114
+
115
+ def loguru_compression(fmt: str | None) -> str | Callable[[str], None] | None:
116
+ """The ``compression`` argument for loguru's ``logger.add``."""
117
+ if fmt == "zst":
118
+ _zstd() # fail now rather than in the writer thread at the first rotation
119
+ return _compress_zstd
120
+ return fmt