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,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
|
+
)
|