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,183 @@
1
+ """Loguru-based logging: quiet console output plus structured per-request records.
2
+
3
+ Per-request records are emitted at loguru's ``TRACE`` level with the record attached as
4
+ ``extra["rr_record"]`` (run manifests as ``extra["rr_run"]``), so they never show up in a normal
5
+ console handler but can be picked up by any sink that filters for them (the JSONL and SQLite
6
+ sinks below do exactly that).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import contextlib
12
+ import json
13
+ import sqlite3
14
+ import sys
15
+ import threading
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from loguru import logger
20
+
21
+ from .config import LogConfig
22
+ from .logfiles import loguru_compression, strip_compression
23
+ from .records import JSON_FIELDS, record_columns
24
+ from .runs import RUN_COLUMNS, RUN_JSON_FIELDS
25
+
26
+ PACKAGE = "responsible_request"
27
+
28
+ _lock = threading.Lock()
29
+ _sinks: dict[tuple[str, str], int] = {} # (kind, resolved path) -> loguru handler id
30
+ _console_handler: int | None = None
31
+
32
+
33
+ def _is_record(record: Any) -> bool:
34
+ return "rr_record" in record["extra"]
35
+
36
+
37
+ def _is_run(record: Any) -> bool:
38
+ return "rr_run" in record["extra"]
39
+
40
+
41
+ def _is_structured(record: Any) -> bool:
42
+ return _is_record(record) or _is_run(record)
43
+
44
+
45
+ def _is_console_message(record: Any) -> bool:
46
+ return not _is_structured(record)
47
+
48
+
49
+ def runs_path(jsonl: str | Path) -> Path:
50
+ """Where run manifests go for a JSONL log: ``requests.jsonl`` -> ``requests.runs.jsonl``."""
51
+ path = strip_compression(Path(jsonl))
52
+ return path.with_name(f"{path.stem}.runs{path.suffix or '.jsonl'}")
53
+
54
+
55
+ def _ensure_table(
56
+ conn: sqlite3.Connection, table: str, columns: tuple[str, ...] | list[str]
57
+ ) -> None:
58
+ cols = ", ".join(f'"{c}"' for c in columns)
59
+ conn.execute(f"CREATE TABLE IF NOT EXISTS {table} ({cols})")
60
+ existing = {row[1] for row in conn.execute(f"PRAGMA table_info({table})")}
61
+ for col in columns:
62
+ if col not in existing: # schema grew in a newer version
63
+ conn.execute(f'ALTER TABLE {table} ADD COLUMN "{col}"')
64
+
65
+
66
+ class SQLiteSink:
67
+ """Loguru sink that inserts request records into a ``requests`` table and run manifests
68
+ into a ``runs`` table."""
69
+
70
+ def __init__(self, path: str | Path) -> None:
71
+ self.path = Path(path)
72
+ self._conn: sqlite3.Connection | None = None
73
+ self._columns = record_columns()
74
+
75
+ def _connect(self) -> sqlite3.Connection:
76
+ if self._conn is None:
77
+ self.path.parent.mkdir(parents=True, exist_ok=True)
78
+ conn = sqlite3.connect(self.path, check_same_thread=False)
79
+ conn.execute("PRAGMA journal_mode=WAL")
80
+ conn.execute("PRAGMA synchronous=NORMAL")
81
+ _ensure_table(conn, "requests", self._columns)
82
+ _ensure_table(conn, "runs", RUN_COLUMNS)
83
+ conn.execute("CREATE INDEX IF NOT EXISTS requests_cache_key ON requests(cache_key)")
84
+ conn.commit()
85
+ self._conn = conn
86
+ return self._conn
87
+
88
+ def write(self, message: Any) -> None:
89
+ extra = message.record["extra"]
90
+ if "rr_run" in extra:
91
+ self._insert("runs", extra["rr_run"], RUN_COLUMNS, RUN_JSON_FIELDS)
92
+ else:
93
+ self._insert("requests", extra["rr_record"], self._columns, JSON_FIELDS)
94
+
95
+ def _insert(
96
+ self,
97
+ table: str,
98
+ data: dict[str, Any],
99
+ columns: tuple[str, ...] | list[str],
100
+ json_fields: tuple[str, ...],
101
+ ) -> None:
102
+ row = {
103
+ k: (json.dumps(v, default=str) if k in json_fields and v is not None else v)
104
+ for k, v in data.items()
105
+ if k in columns
106
+ }
107
+ conn = self._connect()
108
+ cols = ", ".join(f'"{c}"' for c in row)
109
+ marks = ", ".join("?" for _ in row)
110
+ conn.execute(f"INSERT INTO {table} ({cols}) VALUES ({marks})", list(row.values()))
111
+ conn.commit()
112
+
113
+ def stop(self) -> None:
114
+ if self._conn is not None:
115
+ self._conn.close()
116
+ self._conn = None
117
+
118
+
119
+ def setup_logging(config: LogConfig | None = None) -> None:
120
+ """Enable this package's log output and add the sinks requested in ``config``.
121
+
122
+ Safe to call repeatedly: a file or database is only attached once per process.
123
+ """
124
+ global _console_handler
125
+ config = config or LogConfig()
126
+ logger.enable(PACKAGE)
127
+ with _lock:
128
+ if config.console_level is not None and _console_handler is None:
129
+ with contextlib.suppress(ValueError):
130
+ logger.remove(0) # loguru's default stderr handler, if still present
131
+ _console_handler = logger.add(
132
+ sys.stderr, level=config.console_level, filter=_is_console_message
133
+ )
134
+ if config.jsonl is not None:
135
+ key = ("jsonl", str(Path(config.jsonl).resolve()))
136
+ if key not in _sinks:
137
+ _sinks[key] = logger.add(
138
+ config.jsonl,
139
+ level="TRACE",
140
+ filter=_is_record,
141
+ format="{extra[rr_json]}",
142
+ rotation=config.rotation,
143
+ compression=loguru_compression(config.compression),
144
+ enqueue=True,
145
+ )
146
+ runs = runs_path(config.jsonl)
147
+ key = ("jsonl", str(runs.resolve()))
148
+ if key not in _sinks:
149
+ _sinks[key] = logger.add(
150
+ runs,
151
+ level="TRACE",
152
+ filter=_is_run,
153
+ format="{extra[rr_json]}",
154
+ enqueue=True,
155
+ )
156
+ if config.sqlite is not None:
157
+ key = ("sqlite", str(Path(config.sqlite).resolve()))
158
+ if key not in _sinks:
159
+ _sinks[key] = logger.add(
160
+ SQLiteSink(config.sqlite),
161
+ level="TRACE",
162
+ filter=_is_structured,
163
+ format="{message}",
164
+ enqueue=True,
165
+ )
166
+
167
+
168
+ def remove_sinks() -> None:
169
+ """Detach all JSONL/SQLite sinks added by :func:`setup_logging` (flushes pending writes)."""
170
+ with _lock:
171
+ for handler_id in _sinks.values():
172
+ logger.remove(handler_id)
173
+ _sinks.clear()
174
+
175
+
176
+ def emit_record(data: dict[str, Any]) -> None:
177
+ logger.bind(rr_record=data, rr_json=json.dumps(data, default=str)).trace(
178
+ "request {} {} {} -> {}",
179
+ data.get("request_id"),
180
+ data.get("model") or "",
181
+ data.get("path"),
182
+ data.get("status_code"),
183
+ )
@@ -0,0 +1,105 @@
1
+ """Extract provider-specific response metadata into the generic record fields.
2
+
3
+ An extractor is called once per finished request with the record, the response headers and the
4
+ parsed response body (the JSON body, the merged summary of a stream, or None if the body was not
5
+ captured). It fills generic fields (``provider``, ``cost_usd``, ``gateway_request_id``,
6
+ ``upstream_duration_ms``, ...) and puts anything else into ``record.provider_meta``.
7
+
8
+ All default extractors run on every response. Each one only fills fields when the headers or keys
9
+ it knows are present, so no provider has to be configured. Add your own with
10
+ ``rr.AsyncOpenAI(extractors=[...])``::
11
+
12
+ def my_gateway(record, headers, body):
13
+ if "x-my-queue-ms" in headers:
14
+ rr.providers.meta(record, "my_gateway")["queue_ms"] = float(headers["x-my-queue-ms"])
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import contextlib
20
+ from collections.abc import Callable, Mapping
21
+ from typing import Any
22
+
23
+ from .records import RequestRecord
24
+
25
+ Extractor = Callable[[RequestRecord, Mapping[str, str], "dict[str, Any] | None"], None]
26
+
27
+
28
+ def meta(record: RequestRecord, namespace: str) -> dict[str, Any]:
29
+ """The ``provider_meta[namespace]`` dict of ``record``, created if missing."""
30
+ if record.provider_meta is None:
31
+ record.provider_meta = {}
32
+ section: dict[str, Any] = record.provider_meta.setdefault(namespace, {})
33
+ return section
34
+
35
+
36
+ def _float(value: Any) -> float | None:
37
+ if value is None or isinstance(value, bool):
38
+ return None
39
+ with contextlib.suppress(TypeError, ValueError):
40
+ return float(value)
41
+ return None
42
+
43
+
44
+ def openai_compat(
45
+ record: RequestRecord, headers: Mapping[str, str], body: dict[str, Any] | None
46
+ ) -> None:
47
+ """Request id headers of OpenAI and most OpenAI-compatible APIs."""
48
+ for name in ("x-request-id", "request-id"):
49
+ value = headers.get(name)
50
+ if value and record.gateway_request_id is None:
51
+ record.gateway_request_id = value
52
+ duration = _float(headers.get("openai-processing-ms"))
53
+ if duration is not None:
54
+ record.upstream_duration_ms = duration
55
+
56
+
57
+ _LITELLM_PREFIX = "x-litellm-"
58
+
59
+
60
+ def litellm(record: RequestRecord, headers: Mapping[str, str], body: dict[str, Any] | None) -> None:
61
+ """LiteLLM proxy headers: call id, duration, cost; all other ``x-litellm-*`` headers go to
62
+ ``provider_meta["litellm"]``."""
63
+ found = {
64
+ k.lower()[len(_LITELLM_PREFIX) :]: v
65
+ for k, v in headers.items()
66
+ if k.lower().startswith(_LITELLM_PREFIX)
67
+ }
68
+ if not found:
69
+ return
70
+ if found.get("call-id"):
71
+ record.gateway_request_id = found.pop("call-id")
72
+ duration = _float(found.pop("response-duration-ms", None))
73
+ if duration is not None:
74
+ record.upstream_duration_ms = duration
75
+ cost = _float(found.pop("response-cost", None))
76
+ if cost is not None:
77
+ record.cost_usd = cost
78
+ if found:
79
+ meta(record, "litellm").update({k.replace("-", "_"): v for k, v in found.items()})
80
+
81
+
82
+ def openrouter(
83
+ record: RequestRecord, headers: Mapping[str, str], body: dict[str, Any] | None
84
+ ) -> None:
85
+ """OpenRouter and similar routers: upstream ``provider`` and ``usage.cost`` in the body."""
86
+ if not isinstance(body, dict):
87
+ return
88
+ if isinstance(body.get("provider"), str):
89
+ record.provider = body["provider"]
90
+ usage = body.get("usage")
91
+ if isinstance(usage, dict):
92
+ cost = _float(usage.get("cost"))
93
+ if cost is not None:
94
+ record.cost_usd = cost
95
+ extra = {k: usage[k] for k in ("is_byok", "cost_details") if usage.get(k) is not None}
96
+ if extra:
97
+ meta(record, "openrouter").update(extra)
98
+ choices = body.get("choices")
99
+ if isinstance(choices, list) and choices and isinstance(choices[0], dict):
100
+ native = choices[0].get("native_finish_reason")
101
+ if native is not None:
102
+ meta(record, "openrouter")["native_finish_reason"] = native
103
+
104
+
105
+ DEFAULT_EXTRACTORS: tuple[Extractor, ...] = (openai_compat, litellm, openrouter)
File without changes
@@ -0,0 +1,265 @@
1
+ """The per-request record and helpers to fill it from HTTP requests and responses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Iterator
7
+ from contextlib import contextmanager
8
+ from contextvars import ContextVar
9
+ from dataclasses import asdict, dataclass, field
10
+ from datetime import datetime, timezone
11
+ from typing import Any
12
+
13
+ from .config import LogConfig
14
+
15
+ _tags: ContextVar[dict[str, Any] | None] = ContextVar("rr_tags", default=None)
16
+
17
+
18
+ @contextmanager
19
+ def tags(**values: Any) -> Iterator[None]:
20
+ """Attach ``values`` to the ``tags`` field of every request record created inside the block.
21
+
22
+ Example::
23
+
24
+ with rr.tags(experiment="baseline", item=17):
25
+ await client.chat.completions.create(...)
26
+ """
27
+ token = _tags.set({**(_tags.get() or {}), **values})
28
+ try:
29
+ yield
30
+ finally:
31
+ _tags.reset(token)
32
+
33
+
34
+ def current_tags() -> dict[str, Any]:
35
+ return dict(_tags.get() or {})
36
+
37
+
38
+ # Which record fields belong to which toggleable group. Fields not listed are core fields.
39
+ GROUP_FIELDS: dict[str, tuple[str, ...]] = {
40
+ "timing": ("sent_at", "first_byte_at", "finished_at", "wait_s", "ttfb_s", "latency_s"),
41
+ "usage": (
42
+ "prompt_tokens",
43
+ "completion_tokens",
44
+ "total_tokens",
45
+ "cached_tokens",
46
+ "reasoning_tokens",
47
+ "cost_usd",
48
+ ),
49
+ "throttle": ("rpm", "state", "baseline", "load_ratio", "in_flight"),
50
+ "response_meta": (
51
+ "response_id",
52
+ "response_model",
53
+ "finish_reason",
54
+ "system_fingerprint",
55
+ "provider",
56
+ "gateway_request_id",
57
+ "upstream_duration_ms",
58
+ "provider_meta",
59
+ ),
60
+ "params": ("params",),
61
+ "request_body": ("request_body",),
62
+ "response_body": ("response_body",),
63
+ }
64
+
65
+ JSON_FIELDS = ("params", "request_body", "response_body", "tags", "provider_meta")
66
+
67
+
68
+ def utc_iso(ts: float | None) -> str | None:
69
+ if ts is None:
70
+ return None
71
+ return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat(timespec="milliseconds")
72
+
73
+
74
+ @dataclass
75
+ class RequestRecord:
76
+ """Everything recorded about one HTTP request (one attempt, SDK retries are separate)."""
77
+
78
+ request_id: str
79
+ timestamp: str | None # when the request was issued (queued), UTC ISO 8601
80
+ method: str
81
+ path: str
82
+ model: str | None = None
83
+ stream: bool = False
84
+ attempt: int = 0
85
+ status_code: int | None = None
86
+ error: str | None = None
87
+ tags: dict[str, Any] = field(default_factory=dict)
88
+ cache_key: str | None = None # hash of URL, JSON body and key tags (see CacheConfig)
89
+ cache_hit: bool = False # served from the cache, not sent to the server
90
+ run_id: str | None = None # links the record to its run manifest (see RunConfig)
91
+
92
+ # timing
93
+ sent_at: str | None = None
94
+ first_byte_at: str | None = None
95
+ finished_at: str | None = None
96
+ wait_s: float | None = None # time spent waiting for the rate limiter
97
+ ttfb_s: float | None = None # time to first response byte (≈ TTFT when streaming)
98
+ latency_s: float | None = None # sent -> response fully received
99
+
100
+ # usage
101
+ prompt_tokens: int | None = None
102
+ completion_tokens: int | None = None
103
+ total_tokens: int | None = None
104
+ cached_tokens: int | None = None
105
+ reasoning_tokens: int | None = None
106
+ cost_usd: float | None = None # reported by the provider or computed from CostConfig.prices
107
+
108
+ # throttle (state after this observation was processed)
109
+ rpm: float | None = None # rate at which this request was sent
110
+ state: str | None = None
111
+ baseline: float | None = None
112
+ load_ratio: float | None = None
113
+ in_flight: int | None = None
114
+
115
+ # response metadata
116
+ response_id: str | None = None
117
+ response_model: str | None = None
118
+ finish_reason: str | None = None
119
+ system_fingerprint: str | None = None
120
+ provider: str | None = None # upstream provider that served the request (e.g. via OpenRouter)
121
+ gateway_request_id: str | None = None # request/call id assigned by the gateway or API
122
+ upstream_duration_ms: float | None = None # processing time reported by the gateway
123
+ provider_meta: dict[str, Any] | None = None # anything else extractors found
124
+
125
+ params: dict[str, Any] | None = None
126
+ request_body: Any = None
127
+ response_body: Any = None
128
+
129
+ def to_dict(self, config: LogConfig | None = None) -> dict[str, Any]:
130
+ data = asdict(self)
131
+ if config is not None:
132
+ for group, names in GROUP_FIELDS.items():
133
+ if not config.enabled(group):
134
+ for name in names:
135
+ data.pop(name, None)
136
+ return data
137
+
138
+
139
+ def record_columns() -> list[str]:
140
+ return list(RequestRecord.__dataclass_fields__)
141
+
142
+
143
+ # --------------------------------------------------------------------------- request parsing
144
+
145
+ _BODY_KEYS = {"messages", "input", "prompt"}
146
+
147
+
148
+ @dataclass
149
+ class RequestInfo:
150
+ """What we could learn from an outgoing request body."""
151
+
152
+ model: str | None = None
153
+ stream: bool = False
154
+ uses_server_tools: bool = False
155
+ body: Any = None # parsed JSON body (None for non-JSON bodies)
156
+
157
+ @property
158
+ def params(self) -> dict[str, Any] | None:
159
+ if not isinstance(self.body, dict):
160
+ return None
161
+ return {k: v for k, v in self.body.items() if k not in _BODY_KEYS}
162
+
163
+
164
+ def parse_request_body(content: bytes, content_type: str | None) -> RequestInfo:
165
+ if not content or "json" not in (content_type or ""):
166
+ return RequestInfo()
167
+ try:
168
+ body = json.loads(content)
169
+ except ValueError:
170
+ return RequestInfo()
171
+ if not isinstance(body, dict):
172
+ return RequestInfo(body=body)
173
+ tools = body.get("tools") or []
174
+ server_tools = any(
175
+ isinstance(t, dict) and t.get("type") not in (None, "function", "custom") for t in tools
176
+ )
177
+ return RequestInfo(
178
+ model=body.get("model") if isinstance(body.get("model"), str) else None,
179
+ stream=bool(body.get("stream")),
180
+ uses_server_tools=server_tools,
181
+ body=body,
182
+ )
183
+
184
+
185
+ # --------------------------------------------------------------------------- response parsing
186
+
187
+
188
+ def apply_usage(record: RequestRecord, usage: Any) -> None:
189
+ if not isinstance(usage, dict):
190
+ return
191
+ record.prompt_tokens = usage.get("prompt_tokens", usage.get("input_tokens"))
192
+ record.completion_tokens = usage.get("completion_tokens", usage.get("output_tokens"))
193
+ record.total_tokens = usage.get("total_tokens")
194
+ prompt_details = usage.get("prompt_tokens_details") or usage.get("input_tokens_details") or {}
195
+ completion_details = (
196
+ usage.get("completion_tokens_details") or usage.get("output_tokens_details") or {}
197
+ )
198
+ if isinstance(prompt_details, dict):
199
+ record.cached_tokens = prompt_details.get("cached_tokens")
200
+ if isinstance(completion_details, dict):
201
+ record.reasoning_tokens = completion_details.get("reasoning_tokens")
202
+
203
+
204
+ def apply_json_response(record: RequestRecord, body: Any) -> None:
205
+ if not isinstance(body, dict):
206
+ return
207
+ record.response_id = body.get("id") if isinstance(body.get("id"), str) else None
208
+ record.response_model = body.get("model") if isinstance(body.get("model"), str) else None
209
+ choices = body.get("choices")
210
+ if isinstance(choices, list) and choices and isinstance(choices[0], dict):
211
+ record.finish_reason = choices[0].get("finish_reason")
212
+ if isinstance(body.get("system_fingerprint"), str):
213
+ record.system_fingerprint = body["system_fingerprint"]
214
+ apply_usage(record, body.get("usage"))
215
+
216
+
217
+ def parse_sse(text: str) -> tuple[list[dict[str, Any]], dict[str, Any]]:
218
+ """Parse an OpenAI-style SSE stream into its JSON events and a merged response summary."""
219
+ events: list[dict[str, Any]] = []
220
+ for line in text.splitlines():
221
+ if not line.startswith("data:"):
222
+ continue
223
+ data = line[5:].strip()
224
+ if not data or data == "[DONE]":
225
+ continue
226
+ try:
227
+ event = json.loads(data)
228
+ except ValueError:
229
+ continue
230
+ if isinstance(event, dict):
231
+ events.append(event)
232
+
233
+ summary: dict[str, Any] = {}
234
+ content: list[str] = []
235
+ reasoning: list[str] = []
236
+ for event in events:
237
+ for key in ("id", "model", "provider", "system_fingerprint"):
238
+ if isinstance(event.get(key), str):
239
+ summary[key] = event[key]
240
+ if event.get("usage"):
241
+ summary["usage"] = event["usage"]
242
+ choices = event.get("choices")
243
+ if isinstance(choices, list) and choices and isinstance(choices[0], dict):
244
+ choice = choices[0]
245
+ if choice.get("finish_reason"):
246
+ summary["finish_reason"] = choice["finish_reason"]
247
+ delta = choice.get("delta") or {}
248
+ if isinstance(delta, dict):
249
+ if isinstance(delta.get("content"), str):
250
+ content.append(delta["content"])
251
+ for key in ("reasoning_content", "reasoning"):
252
+ if isinstance(delta.get(key), str):
253
+ reasoning.append(delta[key])
254
+ summary["content"] = "".join(content)
255
+ if reasoning:
256
+ summary["reasoning"] = "".join(reasoning)
257
+ return events, summary
258
+
259
+
260
+ def apply_sse_response(record: RequestRecord, summary: dict[str, Any]) -> None:
261
+ record.response_id = summary.get("id")
262
+ record.response_model = summary.get("model")
263
+ record.finish_reason = summary.get("finish_reason")
264
+ record.system_fingerprint = summary.get("system_fingerprint")
265
+ apply_usage(record, summary.get("usage"))
@@ -0,0 +1,116 @@
1
+ """Run manifests: what is needed to reproduce the requests of one client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import json
7
+ import os
8
+ import platform
9
+ import socket
10
+ import subprocess
11
+ import sys
12
+ import time
13
+ import uuid
14
+ from collections.abc import Mapping
15
+ from importlib.metadata import PackageNotFoundError, version
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from loguru import logger
20
+
21
+ from .config import RunConfig
22
+ from .records import utc_iso
23
+
24
+ RUN_COLUMNS = (
25
+ "run_id",
26
+ "name",
27
+ "started_at",
28
+ "endpoint",
29
+ "metadata",
30
+ "versions",
31
+ "platform",
32
+ "hostname",
33
+ "argv",
34
+ "cwd",
35
+ "git",
36
+ "config",
37
+ )
38
+ RUN_JSON_FIELDS = ("metadata", "versions", "argv", "git", "config")
39
+
40
+
41
+ def _version(package: str) -> str | None:
42
+ try:
43
+ return version(package)
44
+ except PackageNotFoundError:
45
+ return None
46
+
47
+
48
+ def _git(cwd: str) -> dict[str, Any] | None:
49
+ def run(*args: str) -> str:
50
+ return subprocess.run(
51
+ ["git", *args], cwd=cwd, capture_output=True, text=True, timeout=5, check=True
52
+ ).stdout.strip()
53
+
54
+ try:
55
+ commit = run("rev-parse", "HEAD")
56
+ dirty = bool(run("status", "--porcelain", "--untracked-files=no"))
57
+ except (OSError, subprocess.SubprocessError):
58
+ return None
59
+ return {"commit": commit, "dirty": dirty}
60
+
61
+
62
+ def _default(value: Any) -> Any:
63
+ if isinstance(value, Path):
64
+ return str(value)
65
+ if isinstance(value, (set, frozenset)):
66
+ return sorted(value, key=repr)
67
+ return repr(value)
68
+
69
+
70
+ def to_jsonable(value: Any) -> Any:
71
+ """Dataclasses and mappings as plain JSON types (callables and other objects as ``repr``)."""
72
+ if dataclasses.is_dataclass(value) and not isinstance(value, type):
73
+ value = {f.name: getattr(value, f.name) for f in dataclasses.fields(value)}
74
+ if isinstance(value, Mapping):
75
+ return {str(k): to_jsonable(v) for k, v in value.items()}
76
+ if isinstance(value, (list, tuple)):
77
+ return [to_jsonable(v) for v in value]
78
+ return json.loads(json.dumps(value, default=_default))
79
+
80
+
81
+ class Run:
82
+ """The run of one client: its ``run_id`` and the manifest written on the first request."""
83
+
84
+ def __init__(self, config: RunConfig | None = None, **configs: Any) -> None:
85
+ self.config = config or RunConfig()
86
+ self.run_id = uuid.uuid4().hex
87
+ cwd = os.getcwd()
88
+ self.manifest: dict[str, Any] = {
89
+ "run_id": self.run_id,
90
+ "name": self.config.name,
91
+ "started_at": utc_iso(time.time()),
92
+ "endpoint": None,
93
+ "metadata": to_jsonable(self.config.metadata),
94
+ "versions": {
95
+ "responsible_request": _version("responsible-request"),
96
+ "openai": _version("openai"),
97
+ "python": platform.python_version(),
98
+ },
99
+ "platform": platform.platform(),
100
+ "hostname": socket.gethostname(),
101
+ "argv": list(sys.argv),
102
+ "cwd": cwd,
103
+ "git": _git(cwd) if self.config.capture_git else None,
104
+ "config": to_jsonable(configs),
105
+ }
106
+ self.emitted = False
107
+
108
+ def emit(self, endpoint: str) -> None:
109
+ """Write the manifest (once), with the scheme and host of the first request."""
110
+ if self.emitted:
111
+ return
112
+ self.emitted = True
113
+ self.manifest["endpoint"] = endpoint
114
+ logger.bind(rr_run=self.manifest, rr_json=json.dumps(self.manifest)).trace(
115
+ "run {} started", self.run_id
116
+ )