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,94 @@
1
+ """Responsible LLM requests for research: polite (load-aware throttling), reproducible (request
2
+ logs, run manifests, decoding presets) and cost-safe (response cache, cost tracking, budgets).
3
+ Works with any OpenAI-compatible endpoint (LiteLLM, OpenRouter, vLLM, Ollama, OpenAI, ...).
4
+
5
+ Typical use::
6
+
7
+ import responsible_request as rr
8
+
9
+ client = rr.AsyncOpenAI(base_url=..., api_key=..., log=rr.LogConfig(sqlite="requests.db"))
10
+ response = await client.chat.completions.create(model=..., messages=..., **rr.reproducible())
11
+ """
12
+
13
+ from importlib.metadata import PackageNotFoundError, version
14
+
15
+ from loguru import logger
16
+
17
+ from . import providers
18
+ from .analysis import load_records, load_runs
19
+ from .cache import ResponseCache
20
+ from .client import AsyncOpenAI, get_throttle, http_client
21
+ from .config import (
22
+ FIELD_GROUPS,
23
+ CacheConfig,
24
+ CostConfig,
25
+ LogConfig,
26
+ Price,
27
+ RunConfig,
28
+ ThrottleConfig,
29
+ )
30
+ from .controller import State, ThrottleController
31
+ from .cost import BudgetExceeded, CostTracker
32
+ from .estimator import LatencyEstimator, LoadEstimator
33
+ from .helpers import (
34
+ StructuredOutputError,
35
+ cached_system_message,
36
+ calibrate,
37
+ extract_json,
38
+ reproducible,
39
+ response_format_from_model,
40
+ run_batch,
41
+ run_batch_sync,
42
+ structured,
43
+ )
44
+ from .logging import remove_sinks, setup_logging
45
+ from .records import RequestRecord, tags
46
+ from .throttle import Throttle
47
+ from .transport import ThrottledTransport
48
+
49
+ try:
50
+ __version__ = version("responsible-request")
51
+ except PackageNotFoundError: # pragma: no cover
52
+ __version__ = "0.0.0"
53
+
54
+ # Libraries should stay silent unless the application opts in (loguru convention).
55
+ logger.disable(__name__)
56
+
57
+ __all__ = [
58
+ "FIELD_GROUPS",
59
+ "AsyncOpenAI",
60
+ "BudgetExceeded",
61
+ "CacheConfig",
62
+ "CostConfig",
63
+ "CostTracker",
64
+ "LatencyEstimator",
65
+ "LoadEstimator",
66
+ "LogConfig",
67
+ "Price",
68
+ "RequestRecord",
69
+ "ResponseCache",
70
+ "RunConfig",
71
+ "State",
72
+ "StructuredOutputError",
73
+ "Throttle",
74
+ "ThrottleConfig",
75
+ "ThrottleController",
76
+ "ThrottledTransport",
77
+ "__version__",
78
+ "cached_system_message",
79
+ "calibrate",
80
+ "extract_json",
81
+ "get_throttle",
82
+ "http_client",
83
+ "load_records",
84
+ "load_runs",
85
+ "providers",
86
+ "remove_sinks",
87
+ "reproducible",
88
+ "response_format_from_model",
89
+ "run_batch",
90
+ "run_batch_sync",
91
+ "setup_logging",
92
+ "structured",
93
+ "tags",
94
+ ]
@@ -0,0 +1,31 @@
1
+ """Resolve the HTTP library used by the installed ``openai`` SDK.
2
+
3
+ ``openai<3`` is built on ``httpx``; ``openai>=3`` is built on its fork ``httpx2``. Both expose the
4
+ same transport API, so we pick whichever one ``openai.DefaultAsyncHttpxClient`` derives from.
5
+
6
+ The library's per-request ``INFO`` line ("HTTP Request: POST ...") is silenced, since every
7
+ request is already recorded by our transport. Lower the level again to get it back.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import importlib
13
+ import logging
14
+ from types import ModuleType
15
+
16
+ import openai
17
+
18
+
19
+ def _resolve() -> ModuleType:
20
+ for base in openai.DefaultAsyncHttpxClient.__mro__:
21
+ root = base.__module__.split(".")[0]
22
+ if root in ("httpx", "httpx2"):
23
+ return importlib.import_module(root)
24
+ return importlib.import_module("httpx") # pragma: no cover
25
+
26
+
27
+ httpx = _resolve()
28
+
29
+ logging.getLogger(httpx.__name__).setLevel(logging.WARNING)
30
+
31
+ __all__ = ["httpx"]
@@ -0,0 +1,81 @@
1
+ """Read logged request records back for analysis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sqlite3
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from .logfiles import log_segments, open_binary, strip_compression
11
+ from .logging import runs_path
12
+ from .records import JSON_FIELDS
13
+ from .runs import RUN_JSON_FIELDS
14
+
15
+ _JSONL_SUFFIXES = (".jsonl", ".json", ".log")
16
+
17
+
18
+ def _is_jsonl(path: Path) -> bool:
19
+ return strip_compression(path).suffix in _JSONL_SUFFIXES
20
+
21
+
22
+ def load_records(path: str | Path, *, as_dataframe: bool | None = None) -> Any:
23
+ """Load request records from a JSONL file or SQLite database written by this package.
24
+
25
+ For a JSONL log, the segments rotated away from it are read too (oldest first, compressed or
26
+ not). A compressed file (e.g. ``requests.jsonl.zst``) is read on its own.
27
+
28
+ Returns a pandas DataFrame if pandas is installed (or ``as_dataframe=True``), otherwise a
29
+ list of dicts.
30
+ """
31
+ path = Path(path)
32
+ if _is_jsonl(path):
33
+ rows = [row for segment in log_segments(path) for row in _read_jsonl(segment)]
34
+ else:
35
+ rows = _read_table(path, "requests", JSON_FIELDS)
36
+ return _output(rows, as_dataframe)
37
+
38
+
39
+ def load_runs(path: str | Path, *, as_dataframe: bool | None = None) -> Any:
40
+ """Load run manifests (see :class:`responsible_request.RunConfig`) from the SQLite database,
41
+ or for a JSONL log (``requests.jsonl``) from ``requests.runs.jsonl`` next to it."""
42
+ path = Path(path)
43
+ if _is_jsonl(path):
44
+ runs = path if strip_compression(path).stem.endswith(".runs") else runs_path(path)
45
+ rows = _read_jsonl(runs) if runs.exists() else []
46
+ else:
47
+ rows = _read_table(path, "runs", RUN_JSON_FIELDS)
48
+ return _output(rows, as_dataframe)
49
+
50
+
51
+ def _read_jsonl(path: Path) -> list[dict[str, Any]]:
52
+ with open_binary(path) as fh:
53
+ return [json.loads(line) for line in fh if line.strip()]
54
+
55
+
56
+ def _read_table(path: Path, table: str, json_fields: tuple[str, ...]) -> list[dict[str, Any]]:
57
+ conn = sqlite3.connect(path)
58
+ conn.row_factory = sqlite3.Row
59
+ try:
60
+ rows = [dict(r) for r in conn.execute(f"SELECT * FROM {table}")]
61
+ except sqlite3.OperationalError: # table does not exist (e.g. no runs in an old database)
62
+ rows = []
63
+ finally:
64
+ conn.close()
65
+ for row in rows:
66
+ for key in json_fields:
67
+ if isinstance(row.get(key), str):
68
+ row[key] = json.loads(row[key])
69
+ return rows
70
+
71
+
72
+ def _output(rows: list[dict[str, Any]], as_dataframe: bool | None) -> Any:
73
+ if as_dataframe is False:
74
+ return rows
75
+ try:
76
+ import pandas as pd
77
+ except ImportError:
78
+ if as_dataframe:
79
+ raise ImportError("install pandas: pip install responsible-request[pandas]") from None
80
+ return rows
81
+ return pd.DataFrame(rows)
@@ -0,0 +1,59 @@
1
+ """Serve requests from previously logged request records."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from typing import Any
8
+
9
+ from .config import CacheConfig, LogConfig
10
+ from .sources import RecordSource, open_source
11
+
12
+
13
+ def cache_key(url: str, body: Any, salt: dict[str, Any] | None = None) -> str:
14
+ """Hash of the request URL, its canonical JSON body and the salt (selected tag values)."""
15
+ payload = {"url": url, "body": body}
16
+ if salt:
17
+ payload["salt"] = salt
18
+ canonical = json.dumps(
19
+ payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=str
20
+ )
21
+ return hashlib.sha256(canonical.encode()).hexdigest()
22
+
23
+
24
+ class ResponseCache:
25
+ """Response lookup by cache key, backed by the records of a SQLite database or JSONL file."""
26
+
27
+ def __init__(self, config: CacheConfig, log: LogConfig | None = None) -> None:
28
+ self.config = config
29
+ target: CacheConfig | LogConfig | None = config
30
+ if config.sqlite is None and config.jsonl is None: # read the records we write
31
+ target = log
32
+ if log is not None and not log.enabled("response_body"):
33
+ raise ValueError("the cache reads responses from the log: enable response_body")
34
+ source = open_source(target)
35
+ if source is None:
36
+ raise ValueError(
37
+ "the cache needs records to read from: set LogConfig(sqlite=...) or "
38
+ "LogConfig(jsonl=...), or CacheConfig(sqlite=...) or CacheConfig(jsonl=...)"
39
+ )
40
+ self._source: RecordSource = source
41
+ self.hits = 0
42
+ self.misses = 0
43
+
44
+ def salt(self, tags: dict[str, Any]) -> dict[str, Any]:
45
+ return {name: tags[name] for name in self.config.key_tags if name in tags}
46
+
47
+ def get(self, key: str) -> dict[str, Any] | None:
48
+ body = self._source.get(key)
49
+ if body is None:
50
+ self.misses += 1
51
+ else:
52
+ self.hits += 1
53
+ return body
54
+
55
+ def stats(self) -> dict[str, int]:
56
+ return {"hits": self.hits, "misses": self.misses}
57
+
58
+ def close(self) -> None:
59
+ self._source.close()
@@ -0,0 +1,169 @@
1
+ """Drop-in OpenAI client and an ``http_client`` factory for existing code."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable, Mapping
6
+ from typing import Any
7
+
8
+ import openai
9
+ from loguru import logger
10
+
11
+ from .cache import ResponseCache
12
+ from .config import CacheConfig, CostConfig, LogConfig, RunConfig, ThrottleConfig
13
+ from .cost import BudgetExceeded, CostTracker
14
+ from .logging import setup_logging
15
+ from .providers import DEFAULT_EXTRACTORS, Extractor
16
+ from .runs import Run
17
+ from .throttle import Throttle
18
+ from .transport import ThrottledTransport
19
+
20
+
21
+ def http_client(
22
+ throttle: ThrottleConfig | Throttle | None = None,
23
+ log: LogConfig | bool | None = True,
24
+ *,
25
+ default_params: Mapping[str, Any] | None = None,
26
+ inject_stream_usage: bool = True,
27
+ cache: CacheConfig | bool | None = None,
28
+ cost: CostConfig | bool | None = None,
29
+ run: RunConfig | str | None = None,
30
+ extractors: Iterable[Extractor] = (),
31
+ transport: Any = None,
32
+ **client_kwargs: Any,
33
+ ) -> Any:
34
+ """Build an async HTTP client for the OpenAI SDK that paces, measures and logs requests.
35
+
36
+ Use it to add throttling to existing code::
37
+
38
+ client = openai.AsyncOpenAI(http_client=rr.http_client(rr.ThrottleConfig(max_rpm=120)))
39
+
40
+ :param throttle: Throttle configuration (or an existing :class:`Throttle` to share state).
41
+ :param log: ``True`` for console output only, a :class:`LogConfig` to also write request
42
+ records to JSONL/SQLite, ``False``/``None`` to leave logging disabled.
43
+ :param default_params: Chat completion parameters added to every request that does not set
44
+ them itself, e.g. ``rr.reproducible()``.
45
+ :param inject_stream_usage: Request token usage for streamed chat completions.
46
+ :param cache: ``True`` or a :class:`CacheConfig` to answer requests that were already
47
+ answered successfully from the logged records instead of sending them again.
48
+ :param cost: ``True`` or a :class:`CostConfig` to track the cost of every request and
49
+ optionally enforce a budget.
50
+ :param run: A :class:`RunConfig` (or just a run name) for the run manifest written on the
51
+ first request. Every record carries the ``run_id``.
52
+ :param extractors: Additional response metadata extractors (see
53
+ :mod:`responsible_request.providers`), run after the built-in ones.
54
+ :param transport: Underlying transport (defaults to a plain async HTTP transport).
55
+ :param client_kwargs: Passed to ``openai.DefaultAsyncHttpxClient`` (e.g. ``timeout``).
56
+ """
57
+ state = throttle if isinstance(throttle, Throttle) else Throttle(throttle)
58
+ log_config = LogConfig() if log is True else (log or None)
59
+ cache_config = CacheConfig() if cache is True else (cache or None)
60
+ response_cache = ResponseCache(cache_config, log_config) if cache_config is not None else None
61
+ cost_config = CostConfig() if cost is True else (cost or None)
62
+ tracker = CostTracker(cost_config, log_config) if cost_config is not None else None
63
+ run_config = RunConfig(name=run) if isinstance(run, str) else run
64
+ rr_run = Run(
65
+ run_config,
66
+ throttle=state.config,
67
+ log=log_config,
68
+ cache=cache_config,
69
+ cost=cost_config,
70
+ default_params=default_params,
71
+ )
72
+ if log_config is not None:
73
+ setup_logging(log_config)
74
+ rr_transport = ThrottledTransport(
75
+ state,
76
+ log=log_config,
77
+ default_params=default_params,
78
+ inject_stream_usage=inject_stream_usage,
79
+ cache=response_cache,
80
+ cost=tracker,
81
+ run=rr_run,
82
+ extractors=(*DEFAULT_EXTRACTORS, *extractors),
83
+ transport=transport,
84
+ )
85
+ client = openai.DefaultAsyncHttpxClient(transport=rr_transport, **client_kwargs)
86
+ client.rr_throttle = state # type: ignore[attr-defined]
87
+ client.rr_cache = response_cache # type: ignore[attr-defined]
88
+ client.rr_cost = tracker # type: ignore[attr-defined]
89
+ client.rr_run_id = rr_run.run_id # type: ignore[attr-defined]
90
+ return client
91
+
92
+
93
+ def get_throttle(client: Any) -> Throttle:
94
+ """Return the :class:`Throttle` behind an OpenAI client created with :func:`http_client`."""
95
+ throttle = getattr(client, "throttle", None)
96
+ if isinstance(throttle, Throttle):
97
+ return throttle
98
+ inner = getattr(client, "_client", client) # the SDK keeps its http client in ``_client``
99
+ throttle = getattr(inner, "rr_throttle", None)
100
+ if not isinstance(throttle, Throttle):
101
+ raise TypeError("client was not created with responsible_request")
102
+ return throttle
103
+
104
+
105
+ class AsyncOpenAI(openai.AsyncOpenAI):
106
+ """``openai.AsyncOpenAI`` with load-aware throttling and request logging.
107
+
108
+ Accepts every argument of ``openai.AsyncOpenAI`` plus ``throttle``, ``log``,
109
+ ``default_params``, ``cache``, ``cost``, ``run`` and ``extractors`` (see :func:`http_client`).
110
+ The throttle state is available as ``client.throttle``; ``client.throttle.stats()`` shows the
111
+ current rate and load per model. ``client.cache.stats()`` counts cache hits and misses and
112
+ ``client.cost.stats()`` shows the spent cost (both are None unless enabled).
113
+ ``client.run_id`` identifies this client's records and run manifest.
114
+ """
115
+
116
+ throttle: Throttle
117
+ cache: ResponseCache | None
118
+ cost: CostTracker | None
119
+ run_id: str
120
+
121
+ def __init__(
122
+ self,
123
+ *,
124
+ throttle: ThrottleConfig | Throttle | None = None,
125
+ log: LogConfig | bool | None = True,
126
+ default_params: Mapping[str, Any] | None = None,
127
+ cache: CacheConfig | bool | None = None,
128
+ cost: CostConfig | bool | None = None,
129
+ run: RunConfig | str | None = None,
130
+ extractors: Iterable[Extractor] = (),
131
+ http_client: Any = None,
132
+ **kwargs: Any,
133
+ ) -> None:
134
+ if http_client is None:
135
+ http_client = _http_client(
136
+ throttle,
137
+ log,
138
+ default_params=default_params,
139
+ cache=cache,
140
+ cost=cost,
141
+ run=run,
142
+ extractors=extractors,
143
+ )
144
+ elif getattr(http_client, "rr_throttle", None) is None:
145
+ raise TypeError(
146
+ "pass throttle/log options instead of http_client, or build the http client "
147
+ "with responsible_request.http_client()"
148
+ )
149
+ super().__init__(http_client=http_client, **kwargs)
150
+ self.throttle = http_client.rr_throttle
151
+ self.cache = http_client.rr_cache
152
+ self.cost = http_client.rr_cost
153
+ self.run_id = http_client.rr_run_id
154
+
155
+ async def request(self, *args: Any, **kwargs: Any) -> Any:
156
+ try:
157
+ return await super().request(*args, **kwargs)
158
+ except openai.APIConnectionError as exc:
159
+ # older SDKs wrap exceptions raised by the transport
160
+ if isinstance(exc.__cause__, BudgetExceeded):
161
+ raise exc.__cause__ from None
162
+ raise
163
+
164
+ async def close(self) -> None:
165
+ await super().close()
166
+ await logger.complete()
167
+
168
+
169
+ _http_client = http_client