torrix-sdk 1.1.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.
- torrix/__init__.py +157 -0
- torrix/_client.py +197 -0
- torrix/_context.py +53 -0
- torrix/_wrap.py +642 -0
- torrix/py.typed +0 -0
- torrix_sdk-1.1.0.dist-info/METADATA +78 -0
- torrix_sdk-1.1.0.dist-info/RECORD +10 -0
- torrix_sdk-1.1.0.dist-info/WHEEL +5 -0
- torrix_sdk-1.1.0.dist-info/licenses/LICENSE +21 -0
- torrix_sdk-1.1.0.dist-info/top_level.txt +1 -0
torrix/__init__.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""torrix — self-hosted LLM observability SDK.
|
|
2
|
+
|
|
3
|
+
Intercept and log every model call your application makes to a Torrix
|
|
4
|
+
instance. Two usage styles are supported:
|
|
5
|
+
|
|
6
|
+
``torrix.init(...)`` then ``torrix.wrap()``
|
|
7
|
+
Apply a global patch to the OpenAI or Anthropic SDK so
|
|
8
|
+
``chat.completions.create`` / ``messages.create`` are captured
|
|
9
|
+
automatically (sync and async, streaming included).
|
|
10
|
+
|
|
11
|
+
``torrix.ingest(record)``
|
|
12
|
+
Log arbitrary run records manually — useful for providers without a
|
|
13
|
+
ready-made wrapper or for custom tooling. Records are delivered by a
|
|
14
|
+
background thread and flushed on process exit.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Any, Dict, List, Optional, Union
|
|
20
|
+
|
|
21
|
+
from ._client import TorrixClient, TorrixError
|
|
22
|
+
from ._context import current_context, run_context
|
|
23
|
+
from ._wrap import patch_classes as _patch_classes
|
|
24
|
+
from ._wrap import wrap as _wrap_sdk
|
|
25
|
+
from ._wrap import unwrap as _unpatch
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"init",
|
|
29
|
+
"wrap",
|
|
30
|
+
"unwrap",
|
|
31
|
+
"ingest",
|
|
32
|
+
"flush",
|
|
33
|
+
"current",
|
|
34
|
+
"get_client",
|
|
35
|
+
"run_context",
|
|
36
|
+
"current_run_context",
|
|
37
|
+
"TorrixClient",
|
|
38
|
+
"TorrixError",
|
|
39
|
+
]
|
|
40
|
+
__version__ = "1.1.0"
|
|
41
|
+
|
|
42
|
+
_DEFAULT: Optional[TorrixClient] = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def init(
|
|
46
|
+
api_key: Optional[str] = None,
|
|
47
|
+
base_url: Optional[str] = None,
|
|
48
|
+
*,
|
|
49
|
+
project: Optional[str] = None,
|
|
50
|
+
sample_rate: float = 1.0,
|
|
51
|
+
timeout: float = 10.0,
|
|
52
|
+
enabled: bool = True,
|
|
53
|
+
background: bool = True,
|
|
54
|
+
auto_instrument: bool = True,
|
|
55
|
+
) -> TorrixClient:
|
|
56
|
+
"""Configure the default Torrix client used by ``wrap`` and ``ingest``.
|
|
57
|
+
|
|
58
|
+
``api_key`` is one of your Torrix API keys (``trxk_...``) and ``base_url``
|
|
59
|
+
points at the running Torrix server (default ``http://localhost:8088``).
|
|
60
|
+
The older ``init(base_url, api_key)`` order is detected and accepted.
|
|
61
|
+
``project`` selects the project by id or name; when omitted the account's
|
|
62
|
+
default project is used. ``sample_rate`` in ``(0, 1]`` controls what
|
|
63
|
+
fraction of calls are recorded.
|
|
64
|
+
|
|
65
|
+
Unless ``auto_instrument=False``, init patches the OpenAI and Anthropic SDK
|
|
66
|
+
resource classes (whichever are installed) so calls are traced without an
|
|
67
|
+
explicit ``wrap()``; ``wrap()`` then becomes a no-op returning the existing
|
|
68
|
+
handle.
|
|
69
|
+
"""
|
|
70
|
+
global _DEFAULT
|
|
71
|
+
api_key, base_url = _normalize_endpoint(api_key, base_url)
|
|
72
|
+
_DEFAULT = TorrixClient(
|
|
73
|
+
base_url,
|
|
74
|
+
api_key,
|
|
75
|
+
project=project,
|
|
76
|
+
sample_rate=sample_rate,
|
|
77
|
+
timeout=timeout,
|
|
78
|
+
enabled=enabled,
|
|
79
|
+
background=background,
|
|
80
|
+
)
|
|
81
|
+
if auto_instrument:
|
|
82
|
+
for provider in ("openai", "anthropic"):
|
|
83
|
+
try:
|
|
84
|
+
_patch_classes(provider)
|
|
85
|
+
except Exception:
|
|
86
|
+
# the SDK is optional; a missing library is not an error
|
|
87
|
+
pass
|
|
88
|
+
return _DEFAULT
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _normalize_endpoint(
|
|
92
|
+
api_key: Optional[str],
|
|
93
|
+
base_url: Optional[str],
|
|
94
|
+
) -> tuple[Optional[str], str]:
|
|
95
|
+
default = "http://localhost:8088"
|
|
96
|
+
looks_like_url = isinstance(api_key, str) and api_key.startswith(("http://", "https://"))
|
|
97
|
+
if looks_like_url and (base_url is None or not base_url.startswith(("http://", "https://"))):
|
|
98
|
+
# Legacy init(base_url, api_key) order.
|
|
99
|
+
return base_url or None, api_key.rstrip("/")
|
|
100
|
+
return api_key, (base_url or default).rstrip("/")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def current() -> Optional[TorrixClient]:
|
|
104
|
+
"""Return the default client configured by ``init`` (or ``None``)."""
|
|
105
|
+
return _DEFAULT
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def get_client() -> Optional[TorrixClient]:
|
|
109
|
+
"""Alias for :func:`current`."""
|
|
110
|
+
return _DEFAULT
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def current_run_context() -> Optional[Dict[str, Any]]:
|
|
114
|
+
"""Return the active run context fields, or ``None`` outside a context."""
|
|
115
|
+
return current_context()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def wrap(target: Any = None, provider: Optional[str] = None) -> Any:
|
|
119
|
+
"""Patch the OpenAI or Anthropic SDK to auto-log calls into Torrix.
|
|
120
|
+
|
|
121
|
+
``wrap()`` / ``wrap("anthropic")`` patch the SDK resource classes globally
|
|
122
|
+
(sync, async and streaming). ``wrap(client)`` returns a tracing proxy for a
|
|
123
|
+
single client instance instead. The handle returned by the class-level form
|
|
124
|
+
can be passed to ``unwrap``; proxies are dropped by using the raw client
|
|
125
|
+
again.
|
|
126
|
+
"""
|
|
127
|
+
return _wrap_sdk(target, provider)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def unwrap(handle: Optional[int] = None) -> None:
|
|
131
|
+
"""Restore the original SDK methods for a wrapped ``handle`` (or all)."""
|
|
132
|
+
_unpatch(handle)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def ingest(records: Union[Dict[str, Any], List[Dict[str, Any]]], *, wait: bool = False) -> List[str]:
|
|
136
|
+
"""Log one or several run records through the default client.
|
|
137
|
+
|
|
138
|
+
Background delivery is the default; pass ``wait=True`` to block until the
|
|
139
|
+
records are accepted and receive their ids. Raises :class:`TorrixError` if
|
|
140
|
+
no client has been configured with :func:`init`.
|
|
141
|
+
"""
|
|
142
|
+
if _DEFAULT is None:
|
|
143
|
+
raise TorrixError("torrix not initialized: call torrix.init() first")
|
|
144
|
+
return _DEFAULT.ingest(records, wait=wait)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def flush(timeout: float = 5.0) -> bool:
|
|
148
|
+
"""Wait for queued records to be delivered (5 second default)."""
|
|
149
|
+
if _DEFAULT is None:
|
|
150
|
+
return True
|
|
151
|
+
return _DEFAULT.flush(timeout)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def health() -> bool:
|
|
155
|
+
if _DEFAULT is None:
|
|
156
|
+
return False
|
|
157
|
+
return _DEFAULT.health()
|
torrix/_client.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Low-level ingest client for the Torrix server.
|
|
2
|
+
|
|
3
|
+
Records are queued and delivered by a background daemon thread, so logging
|
|
4
|
+
never blocks a request path. The queue is flushed on interpreter shutdown
|
|
5
|
+
(``atexit``) and can be flushed manually with :meth:`TorrixClient.flush`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import atexit
|
|
11
|
+
import json
|
|
12
|
+
import queue
|
|
13
|
+
import random
|
|
14
|
+
import uuid
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from typing import Any, Dict, List, Optional, Union
|
|
18
|
+
from urllib import request
|
|
19
|
+
|
|
20
|
+
from ._context import current_context
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TorrixError(RuntimeError):
|
|
24
|
+
"""Raised when the Torrix server rejects a payload or cannot be reached."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_BATCH_SIZE = 500
|
|
28
|
+
_FLUSH_TIMEOUT_SECONDS = 5.0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TorrixClient:
|
|
32
|
+
"""Posts run records to a Torrix instance over HTTP."""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
base_url: str,
|
|
37
|
+
api_key: Optional[str] = None,
|
|
38
|
+
*,
|
|
39
|
+
project: Optional[str] = None,
|
|
40
|
+
sample_rate: float = 1.0,
|
|
41
|
+
timeout: float = 10.0,
|
|
42
|
+
enabled: bool = True,
|
|
43
|
+
background: bool = True,
|
|
44
|
+
) -> None:
|
|
45
|
+
self.base_url = base_url.rstrip("/")
|
|
46
|
+
self.api_key = api_key
|
|
47
|
+
self.project = project
|
|
48
|
+
self.sample_rate = _clamp01(sample_rate)
|
|
49
|
+
self.timeout = timeout
|
|
50
|
+
self.enabled = enabled
|
|
51
|
+
self.background = background
|
|
52
|
+
self._accepted = 0
|
|
53
|
+
self._dropped = 0
|
|
54
|
+
self._last_error: Optional[str] = None
|
|
55
|
+
self._queue: "queue.Queue[List[Dict[str, Any]] | None]" = queue.Queue()
|
|
56
|
+
self._worker: Optional[threading.Thread] = None
|
|
57
|
+
self._lock = threading.Lock()
|
|
58
|
+
self._closed = False
|
|
59
|
+
if background:
|
|
60
|
+
self._worker = threading.Thread(target=self._worker_loop, name="torrix-ingest", daemon=True)
|
|
61
|
+
self._worker.start()
|
|
62
|
+
atexit.register(self.close)
|
|
63
|
+
|
|
64
|
+
def ingest(
|
|
65
|
+
self,
|
|
66
|
+
records: Union[Dict[str, Any], List[Dict[str, Any]]],
|
|
67
|
+
*,
|
|
68
|
+
wait: bool = False,
|
|
69
|
+
) -> List[str]:
|
|
70
|
+
"""Send one run record (or a batch) and return the accepted run ids.
|
|
71
|
+
|
|
72
|
+
With the default background mode the call returns immediately (an empty
|
|
73
|
+
list) after queueing; pass ``wait=True`` to deliver synchronously and
|
|
74
|
+
receive the ids. Each record accepts the fields documented for
|
|
75
|
+
``POST /api/ingest``: ``request`` / ``response`` (each ``{"body": ...}``),
|
|
76
|
+
``model``, ``provider``, ``status``, ``error``, ``latency_ms``,
|
|
77
|
+
``start_time`` / ``end_time``, ``input_tokens``, ``output_tokens``,
|
|
78
|
+
``cost_usd``, ``target_url``, ``trace_id``, ``session_id``, ``tags``.
|
|
79
|
+
"""
|
|
80
|
+
if not self.enabled or self._closed:
|
|
81
|
+
return []
|
|
82
|
+
items = [records] if isinstance(records, dict) else list(records)
|
|
83
|
+
if not items:
|
|
84
|
+
return []
|
|
85
|
+
context = current_context()
|
|
86
|
+
if context:
|
|
87
|
+
for record in items:
|
|
88
|
+
if not isinstance(record, dict):
|
|
89
|
+
continue
|
|
90
|
+
record.setdefault("trace_id", context.get("trace_id"))
|
|
91
|
+
if context.get("session_id"):
|
|
92
|
+
record.setdefault("session_id", context["session_id"])
|
|
93
|
+
if context.get("agent_name"):
|
|
94
|
+
record.setdefault("agent_name", context["agent_name"])
|
|
95
|
+
# Calls inside one context are siblings under the context's parent;
|
|
96
|
+
# only a nested context hangs under the latest call of its enclosing one.
|
|
97
|
+
if context.get("parent_run_id"):
|
|
98
|
+
record.setdefault("parent_run_id", context["parent_run_id"])
|
|
99
|
+
if context.get("name") and not record.get("name"):
|
|
100
|
+
record["name"] = context["name"]
|
|
101
|
+
record.setdefault("id", uuid.uuid4().hex)
|
|
102
|
+
context["last_run_id"] = record["id"]
|
|
103
|
+
|
|
104
|
+
accepted: List[str] = []
|
|
105
|
+
for start in range(0, len(items), _BATCH_SIZE):
|
|
106
|
+
batch = [r for r in items[start : start + _BATCH_SIZE] if random.random() < self.sample_rate]
|
|
107
|
+
if not batch:
|
|
108
|
+
continue
|
|
109
|
+
if self.background and not wait:
|
|
110
|
+
self._queue.put(batch)
|
|
111
|
+
else:
|
|
112
|
+
accepted.extend(self._send(batch, timeout=self.timeout))
|
|
113
|
+
self._accepted += len(accepted)
|
|
114
|
+
self._dropped += len(items) - len(accepted)
|
|
115
|
+
return accepted
|
|
116
|
+
|
|
117
|
+
def flush(self, timeout: float = _FLUSH_TIMEOUT_SECONDS) -> bool:
|
|
118
|
+
"""Block until the queue drains or ``timeout`` elapses."""
|
|
119
|
+
if not self.background:
|
|
120
|
+
return True
|
|
121
|
+
deadline = time.monotonic() + timeout
|
|
122
|
+
while time.monotonic() < deadline:
|
|
123
|
+
if self._queue.unfinished_tasks == 0:
|
|
124
|
+
return True
|
|
125
|
+
time.sleep(0.01)
|
|
126
|
+
return self._queue.unfinished_tasks == 0
|
|
127
|
+
|
|
128
|
+
def close(self) -> None:
|
|
129
|
+
"""Flush pending records and stop the worker thread."""
|
|
130
|
+
with self._lock:
|
|
131
|
+
if self._closed:
|
|
132
|
+
return
|
|
133
|
+
self._closed = True
|
|
134
|
+
if self.background:
|
|
135
|
+
self.flush()
|
|
136
|
+
self._queue.put(None)
|
|
137
|
+
if self._worker is not None:
|
|
138
|
+
self._worker.join(timeout=1.0)
|
|
139
|
+
|
|
140
|
+
def stats(self) -> Dict[str, Any]:
|
|
141
|
+
return {
|
|
142
|
+
"accepted": self._accepted,
|
|
143
|
+
"dropped": self._dropped,
|
|
144
|
+
"queued": self._queue.qsize(),
|
|
145
|
+
"last_error": self._last_error,
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
def _worker_loop(self) -> None:
|
|
149
|
+
while True:
|
|
150
|
+
batch = self._queue.get()
|
|
151
|
+
try:
|
|
152
|
+
if batch is None:
|
|
153
|
+
return
|
|
154
|
+
self._send(batch, timeout=min(self.timeout, _FLUSH_TIMEOUT_SECONDS))
|
|
155
|
+
except TorrixError as exc:
|
|
156
|
+
self._last_error = str(exc)
|
|
157
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
158
|
+
self._last_error = str(exc)
|
|
159
|
+
finally:
|
|
160
|
+
self._queue.task_done()
|
|
161
|
+
|
|
162
|
+
def _send(self, batch: List[Dict[str, Any]], *, timeout: float) -> List[str]:
|
|
163
|
+
url = f"{self.base_url}/api/ingest"
|
|
164
|
+
payload = json.dumps({"records": batch}).encode("utf-8")
|
|
165
|
+
headers = {"content-type": "application/json", "user-agent": "torrix-sdk/1.1.0"}
|
|
166
|
+
if self.api_key:
|
|
167
|
+
headers["authorization"] = f"Bearer {self.api_key}"
|
|
168
|
+
if self.project:
|
|
169
|
+
headers["x-torrix-project"] = self.project
|
|
170
|
+
req = request.Request(url, data=payload, headers=headers, method="POST")
|
|
171
|
+
try:
|
|
172
|
+
with request.urlopen(req, timeout=timeout) as resp:
|
|
173
|
+
body = resp.read().decode("utf-8")
|
|
174
|
+
if resp.status >= 400:
|
|
175
|
+
raise TorrixError(f"ingest rejected with HTTP {resp.status}: {body}")
|
|
176
|
+
parsed = json.loads(body) if body else {}
|
|
177
|
+
return list(parsed.get("ids", []))
|
|
178
|
+
except TorrixError:
|
|
179
|
+
self._last_error = "torrix_rejected"
|
|
180
|
+
raise
|
|
181
|
+
except Exception as exc: # network, DNS, timeout
|
|
182
|
+
self._last_error = str(exc)
|
|
183
|
+
raise TorrixError(f"torrix unreachable: {exc}") from exc
|
|
184
|
+
|
|
185
|
+
def health(self) -> bool:
|
|
186
|
+
try:
|
|
187
|
+
with request.urlopen(f"{self.base_url}/health", timeout=self.timeout) as resp:
|
|
188
|
+
return resp.status == 200
|
|
189
|
+
except Exception:
|
|
190
|
+
return False
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _clamp01(value: float) -> float:
|
|
194
|
+
try:
|
|
195
|
+
return min(1.0, max(0.0, float(value)))
|
|
196
|
+
except (TypeError, ValueError):
|
|
197
|
+
return 1.0
|
torrix/_context.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Run context propagation for automatic trace and parent linkage.
|
|
2
|
+
|
|
3
|
+
``torrix.run_context(...)`` groups every call made inside the block into one
|
|
4
|
+
trace and chains parent-child relationships without manual ids, so nested
|
|
5
|
+
``torrix.wrap()`` calls build an agent tree on their own.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import uuid
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
from contextvars import ContextVar
|
|
13
|
+
from typing import Any, Dict, Iterator, Optional
|
|
14
|
+
|
|
15
|
+
_current: ContextVar[Optional[Dict[str, Any]]] = ContextVar("torrix_run_context", default=None)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def current_context() -> Optional[Dict[str, Any]]:
|
|
19
|
+
"""Return the active run context, if any (mostly useful in tests/debugging)."""
|
|
20
|
+
return _current.get()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@contextmanager
|
|
24
|
+
def run_context(
|
|
25
|
+
name: Optional[str] = None,
|
|
26
|
+
trace_id: Optional[str] = None,
|
|
27
|
+
session_id: Optional[str] = None,
|
|
28
|
+
agent_name: Optional[str] = None,
|
|
29
|
+
parent_run_id: Optional[str] = None,
|
|
30
|
+
) -> Iterator[Dict[str, Any]]:
|
|
31
|
+
"""Group calls into one trace and auto-link parents.
|
|
32
|
+
|
|
33
|
+
Records created inside the block inherit the context's ``trace_id``,
|
|
34
|
+
``session_id`` and ``agent_name`` when they do not set their own. Calls in
|
|
35
|
+
the same block are siblings: they all get the block's ``parent_run_id``
|
|
36
|
+
(the enclosing context's latest run when the block was opened). Nested
|
|
37
|
+
blocks therefore hang a subagent's calls under the call that started it,
|
|
38
|
+
without turning sequential calls into one long chain.
|
|
39
|
+
"""
|
|
40
|
+
parent = _current.get() or {}
|
|
41
|
+
context: Dict[str, Any] = {
|
|
42
|
+
"trace_id": trace_id or parent.get("trace_id") or uuid.uuid4().hex,
|
|
43
|
+
"session_id": session_id or parent.get("session_id"),
|
|
44
|
+
"agent_name": agent_name or parent.get("agent_name"),
|
|
45
|
+
"parent_run_id": parent_run_id or parent.get("last_run_id"),
|
|
46
|
+
"name": name or parent.get("name"),
|
|
47
|
+
"last_run_id": None,
|
|
48
|
+
}
|
|
49
|
+
token = _current.set(context)
|
|
50
|
+
try:
|
|
51
|
+
yield context
|
|
52
|
+
finally:
|
|
53
|
+
_current.reset(token)
|
torrix/_wrap.py
ADDED
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
"""Automatic capture of OpenAI and Anthropic SDK calls into Torrix.
|
|
2
|
+
|
|
3
|
+
``wrap("openai")`` and ``wrap("anthropic")`` patch the sync and async resource
|
|
4
|
+
classes so ``chat.completions.create`` / ``messages.create`` calls are recorded
|
|
5
|
+
and forwarded to the configured Torrix instance. Streaming responses are
|
|
6
|
+
accumulated as the caller iterates them, then recorded once at end of stream
|
|
7
|
+
with the reconstructed response text, tool calls and usage.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import time
|
|
14
|
+
import uuid
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
17
|
+
|
|
18
|
+
_TRANSPORT_KEYS = {"timeout", "max_retries", "extra_headers", "extra_query", "extra_body", "api_key", "base_url"}
|
|
19
|
+
|
|
20
|
+
_PATCHED: Dict[int, List[Tuple[Any, Any, str]]] = {}
|
|
21
|
+
_CLASS_PATCHES: Dict[str, int] = {}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def patch_classes(provider: str = "openai") -> int:
|
|
25
|
+
"""Patch the SDK resource classes once per provider; returns the handle.
|
|
26
|
+
|
|
27
|
+
Safe to call repeatedly: a second call returns the existing handle instead
|
|
28
|
+
of stacking another wrapper. Missing SDKs raise ImportError, which callers
|
|
29
|
+
like ``init`` swallow so an uninstalled library cannot crash startup.
|
|
30
|
+
"""
|
|
31
|
+
if provider in _CLASS_PATCHES:
|
|
32
|
+
return _CLASS_PATCHES[provider]
|
|
33
|
+
if provider == "openai":
|
|
34
|
+
import openai.resources.chat.completions as chat
|
|
35
|
+
import openai.resources.completions as base
|
|
36
|
+
|
|
37
|
+
targets = [
|
|
38
|
+
(chat.Completions, "create"),
|
|
39
|
+
(chat.AsyncCompletions, "create"),
|
|
40
|
+
(base.Completions, "create"),
|
|
41
|
+
(base.AsyncCompletions, "create"),
|
|
42
|
+
]
|
|
43
|
+
elif provider == "anthropic":
|
|
44
|
+
import anthropic.resources.messages as messages
|
|
45
|
+
|
|
46
|
+
targets = [
|
|
47
|
+
(messages.Messages, "create"),
|
|
48
|
+
(messages.AsyncMessages, "create"),
|
|
49
|
+
]
|
|
50
|
+
else:
|
|
51
|
+
raise ValueError(f"unsupported provider '{provider}' (expected 'openai' or 'anthropic')")
|
|
52
|
+
|
|
53
|
+
handle = uuid.uuid4().int
|
|
54
|
+
patches = []
|
|
55
|
+
for cls, name in targets:
|
|
56
|
+
original = getattr(cls, name)
|
|
57
|
+
setattr(cls, name, _chain(original, provider))
|
|
58
|
+
patches.append((original, cls, name))
|
|
59
|
+
_PATCHED[handle] = patches
|
|
60
|
+
_CLASS_PATCHES[provider] = handle
|
|
61
|
+
return handle
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def wrap(target: Any = None, provider: Optional[str] = None) -> Any:
|
|
65
|
+
"""Wrap the OpenAI or Anthropic SDK calls into Torrix.
|
|
66
|
+
|
|
67
|
+
Two call styles are supported, matching the original SDK:
|
|
68
|
+
|
|
69
|
+
``torrix.wrap()`` / ``torrix.wrap("anthropic")``
|
|
70
|
+
Patch the SDK's resource classes globally (sync and async), including
|
|
71
|
+
streaming responses. Returns a numeric handle for ``unwrap``.
|
|
72
|
+
|
|
73
|
+
``torrix.wrap(client)``
|
|
74
|
+
Return a lightweight proxy around one client instance. Use the proxy
|
|
75
|
+
in place of the raw client; the original object is untouched.
|
|
76
|
+
"""
|
|
77
|
+
if target is None or isinstance(target, str):
|
|
78
|
+
if isinstance(target, str) and provider is None:
|
|
79
|
+
provider = target
|
|
80
|
+
return patch_classes(provider or "openai")
|
|
81
|
+
resolved = provider or _detect_provider(target)
|
|
82
|
+
return _instrument_client(target, resolved)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _detect_provider(client: Any) -> str:
|
|
86
|
+
module = type(client).__module__.lower()
|
|
87
|
+
name = type(client).__name__.lower()
|
|
88
|
+
return "anthropic" if "anthropic" in module or "anthropic" in name else "openai"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class _OpenAICompletionsProxy:
|
|
92
|
+
def __init__(self, resource: Any) -> None:
|
|
93
|
+
self._resource = resource
|
|
94
|
+
|
|
95
|
+
def __getattr__(self, name: str) -> Any:
|
|
96
|
+
return getattr(self._resource, name)
|
|
97
|
+
|
|
98
|
+
def create(self, *args: Any, **kwargs: Any) -> Any:
|
|
99
|
+
return _create_wrapper(self._resource, self._resource.create, "openai", *args, **kwargs)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class _OpenAIChatProxy:
|
|
103
|
+
def __init__(self, chat: Any) -> None:
|
|
104
|
+
self._chat = chat
|
|
105
|
+
|
|
106
|
+
def __getattr__(self, name: str) -> Any:
|
|
107
|
+
return getattr(self._chat, name)
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def completions(self) -> _OpenAICompletionsProxy:
|
|
111
|
+
return _OpenAICompletionsProxy(self._chat.completions)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class _AnthropicMessagesProxy:
|
|
115
|
+
def __init__(self, resource: Any) -> None:
|
|
116
|
+
self._resource = resource
|
|
117
|
+
|
|
118
|
+
def __getattr__(self, name: str) -> Any:
|
|
119
|
+
return getattr(self._resource, name)
|
|
120
|
+
|
|
121
|
+
def create(self, *args: Any, **kwargs: Any) -> Any:
|
|
122
|
+
return _create_wrapper(self._resource, self._resource.create, "anthropic", *args, **kwargs)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class _OpenAIClientProxy:
|
|
126
|
+
def __init__(self, client: Any) -> None:
|
|
127
|
+
self._client = client
|
|
128
|
+
|
|
129
|
+
def __getattr__(self, name: str) -> Any:
|
|
130
|
+
return getattr(self._client, name)
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def chat(self) -> _OpenAIChatProxy:
|
|
134
|
+
return _OpenAIChatProxy(self._client.chat)
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def completions(self) -> _OpenAICompletionsProxy:
|
|
138
|
+
return _OpenAICompletionsProxy(self._client.completions)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class _AnthropicClientProxy:
|
|
142
|
+
def __init__(self, client: Any) -> None:
|
|
143
|
+
self._client = client
|
|
144
|
+
|
|
145
|
+
def __getattr__(self, name: str) -> Any:
|
|
146
|
+
return getattr(self._client, name)
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def messages(self) -> _AnthropicMessagesProxy:
|
|
150
|
+
return _AnthropicMessagesProxy(self._client.messages)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _instrument_client(client: Any, provider: str) -> Any:
|
|
154
|
+
if provider == "anthropic":
|
|
155
|
+
return _AnthropicClientProxy(client)
|
|
156
|
+
return _OpenAIClientProxy(client)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def unwrap(handle: Optional[int] = None) -> None:
|
|
160
|
+
"""Restore the original SDK methods for a wrapped handle (or all)."""
|
|
161
|
+
handles = list(_PATCHED.keys()) if handle is None else [handle]
|
|
162
|
+
for key in handles:
|
|
163
|
+
for original, cls, name in _PATCHED.pop(key, []):
|
|
164
|
+
if getattr(cls, name, None) is not None:
|
|
165
|
+
setattr(cls, name, original)
|
|
166
|
+
for provider, value in list(_CLASS_PATCHES.items()):
|
|
167
|
+
if value not in _PATCHED:
|
|
168
|
+
_CLASS_PATCHES.pop(provider, None)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _chain(original: Callable[..., Any], provider: str) -> Callable[..., Any]:
|
|
172
|
+
def wrapped(self: Any, *args: Any, **kwargs: Any) -> Any:
|
|
173
|
+
return _create_wrapper(self, original, provider, *args, **kwargs)
|
|
174
|
+
|
|
175
|
+
return wrapped
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _create_wrapper(self: Any, original: Callable[..., Any], provider: str, *args: Any, **kwargs: Any) -> Any:
|
|
179
|
+
from . import current
|
|
180
|
+
|
|
181
|
+
client = current()
|
|
182
|
+
if client is None or not client.enabled:
|
|
183
|
+
return original(self, *args, **kwargs)
|
|
184
|
+
|
|
185
|
+
name = kwargs.pop("torrix_name", None)
|
|
186
|
+
request_body = _request_payload(kwargs)
|
|
187
|
+
start = time.monotonic()
|
|
188
|
+
try:
|
|
189
|
+
result = original(self, *args, **kwargs)
|
|
190
|
+
except Exception as exc:
|
|
191
|
+
_record(
|
|
192
|
+
client,
|
|
193
|
+
provider,
|
|
194
|
+
request_body,
|
|
195
|
+
name=name,
|
|
196
|
+
error=str(exc),
|
|
197
|
+
error_detail=repr(exc),
|
|
198
|
+
latency_ms=_since(start),
|
|
199
|
+
status=_status_for(exc),
|
|
200
|
+
)
|
|
201
|
+
raise
|
|
202
|
+
|
|
203
|
+
if _is_stream(result, kwargs):
|
|
204
|
+
return _wrap_stream(result, client, provider, request_body, start, name)
|
|
205
|
+
|
|
206
|
+
_record(client, provider, request_body, name=name, response=_response_dict(result), latency_ms=_since(start))
|
|
207
|
+
return result
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _is_stream(result: Any, kwargs: Dict[str, Any]) -> bool:
|
|
211
|
+
if kwargs.get("stream") is True:
|
|
212
|
+
return True
|
|
213
|
+
name = type(result).__name__.lower()
|
|
214
|
+
return "stream" in name
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _request_payload(kwargs: Dict[str, Any]) -> Dict[str, Any]:
|
|
218
|
+
payload: Dict[str, Any] = {}
|
|
219
|
+
for key, value in kwargs.items():
|
|
220
|
+
if key in _TRANSPORT_KEYS:
|
|
221
|
+
continue
|
|
222
|
+
payload[key] = _to_jsonable(value)
|
|
223
|
+
return payload
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _record(
|
|
227
|
+
client: Any,
|
|
228
|
+
provider: str,
|
|
229
|
+
request_body: Dict[str, Any],
|
|
230
|
+
*,
|
|
231
|
+
name: Optional[str] = None,
|
|
232
|
+
response: Optional[Dict[str, Any]] = None,
|
|
233
|
+
error: Optional[str] = None,
|
|
234
|
+
error_detail: Optional[str] = None,
|
|
235
|
+
latency_ms: Optional[int] = None,
|
|
236
|
+
status: Optional[int] = None,
|
|
237
|
+
) -> None:
|
|
238
|
+
now = datetime.now(timezone.utc)
|
|
239
|
+
record: Dict[str, Any] = {
|
|
240
|
+
"id": uuid.uuid4().hex,
|
|
241
|
+
"model": request_body.get("model") or None,
|
|
242
|
+
"request": {"body": request_body},
|
|
243
|
+
"provider": provider,
|
|
244
|
+
}
|
|
245
|
+
if name:
|
|
246
|
+
record["name"] = str(name)
|
|
247
|
+
if latency_ms is not None:
|
|
248
|
+
record["latency_ms"] = latency_ms
|
|
249
|
+
record["start_time"] = datetime.fromtimestamp(now.timestamp() - latency_ms / 1000, tz=timezone.utc).isoformat(timespec="milliseconds")
|
|
250
|
+
record["end_time"] = now.isoformat(timespec="milliseconds")
|
|
251
|
+
if response is not None:
|
|
252
|
+
record["response"] = {"body": response}
|
|
253
|
+
record.update(_token_counts(provider, response))
|
|
254
|
+
record.setdefault("status", 200)
|
|
255
|
+
else:
|
|
256
|
+
record["status"] = status if status is not None else (502 if error else 200)
|
|
257
|
+
if error:
|
|
258
|
+
record["error"] = error
|
|
259
|
+
if error_detail:
|
|
260
|
+
record["error_detail"] = error_detail
|
|
261
|
+
try:
|
|
262
|
+
client.ingest(record)
|
|
263
|
+
except Exception:
|
|
264
|
+
pass
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _token_counts(provider: str, response: Dict[str, Any]) -> Dict[str, int]:
|
|
268
|
+
usage = response.get("usage") or {}
|
|
269
|
+
if not isinstance(usage, dict):
|
|
270
|
+
return {}
|
|
271
|
+
counts: Dict[str, int] = {}
|
|
272
|
+
if provider == "anthropic":
|
|
273
|
+
if isinstance(usage.get("input_tokens"), int):
|
|
274
|
+
counts["input_tokens"] = usage["input_tokens"]
|
|
275
|
+
if isinstance(usage.get("output_tokens"), int):
|
|
276
|
+
counts["output_tokens"] = usage["output_tokens"]
|
|
277
|
+
if isinstance(usage.get("cache_read_input_tokens"), int):
|
|
278
|
+
counts["cache_read_tokens"] = usage["cache_read_input_tokens"]
|
|
279
|
+
if isinstance(usage.get("cache_creation_input_tokens"), int):
|
|
280
|
+
counts["cache_creation_tokens"] = usage["cache_creation_input_tokens"]
|
|
281
|
+
return counts
|
|
282
|
+
if isinstance(usage.get("prompt_tokens"), int):
|
|
283
|
+
counts["input_tokens"] = usage["prompt_tokens"]
|
|
284
|
+
if isinstance(usage.get("completion_tokens"), int):
|
|
285
|
+
counts["output_tokens"] = usage["completion_tokens"]
|
|
286
|
+
details = usage.get("prompt_tokens_details")
|
|
287
|
+
if isinstance(details, dict) and isinstance(details.get("cached_tokens"), int):
|
|
288
|
+
counts["cache_read_tokens"] = details["cached_tokens"]
|
|
289
|
+
return counts
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _status_for(exc: Exception) -> Optional[int]:
|
|
293
|
+
status = getattr(exc, "status_code", None)
|
|
294
|
+
return status if isinstance(status, int) else None
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _since(start: float) -> int:
|
|
298
|
+
return int((time.monotonic() - start) * 1000)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _response_dict(obj: Any) -> Dict[str, Any]:
|
|
302
|
+
return _serialize(obj)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _serialize(obj: Any) -> Dict[str, Any]:
|
|
306
|
+
dump = getattr(obj, "model_dump", None)
|
|
307
|
+
if callable(dump):
|
|
308
|
+
try:
|
|
309
|
+
return _to_jsonable(dump()) # type: ignore[call-arg]
|
|
310
|
+
except Exception:
|
|
311
|
+
pass
|
|
312
|
+
dump_json = getattr(obj, "model_dump_json", None)
|
|
313
|
+
if callable(dump_json):
|
|
314
|
+
try:
|
|
315
|
+
return json.loads(str(dump_json()))
|
|
316
|
+
except Exception:
|
|
317
|
+
pass
|
|
318
|
+
return _to_jsonable(vars(obj) if hasattr(obj, "__dict__") else obj)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _to_jsonable(value: Any) -> Any:
|
|
322
|
+
if value is None or isinstance(value, (str, int, float, bool)):
|
|
323
|
+
return value
|
|
324
|
+
if isinstance(value, (list, tuple)):
|
|
325
|
+
return [_to_jsonable(v) for v in value]
|
|
326
|
+
if isinstance(value, dict):
|
|
327
|
+
return {str(k): _to_jsonable(v) for k, v in value.items()}
|
|
328
|
+
dump = getattr(value, "model_dump", None)
|
|
329
|
+
if callable(dump):
|
|
330
|
+
try:
|
|
331
|
+
return _to_jsonable(dump()) # type: ignore[call-arg]
|
|
332
|
+
except Exception:
|
|
333
|
+
pass
|
|
334
|
+
return str(value)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
class _SyncStreamProxy:
|
|
338
|
+
"""Wraps a sync stream, delegating attributes and accumulating chunks."""
|
|
339
|
+
|
|
340
|
+
def __init__(
|
|
341
|
+
self,
|
|
342
|
+
stream: Any,
|
|
343
|
+
accumulate: Callable[[Any], None],
|
|
344
|
+
finish: Callable[[Optional[Exception]], None],
|
|
345
|
+
) -> None:
|
|
346
|
+
self._stream = stream
|
|
347
|
+
self._accumulate = accumulate
|
|
348
|
+
self._finish_fn = finish
|
|
349
|
+
self._finished = False
|
|
350
|
+
|
|
351
|
+
def __getattr__(self, name: str) -> Any:
|
|
352
|
+
return getattr(self._stream, name)
|
|
353
|
+
|
|
354
|
+
def __iter__(self) -> "_SyncStreamProxy":
|
|
355
|
+
return self
|
|
356
|
+
|
|
357
|
+
def __next__(self) -> Any:
|
|
358
|
+
try:
|
|
359
|
+
chunk = next(self._stream)
|
|
360
|
+
except StopIteration:
|
|
361
|
+
self._finish(None)
|
|
362
|
+
raise
|
|
363
|
+
except Exception as exc:
|
|
364
|
+
self._finish(exc)
|
|
365
|
+
raise
|
|
366
|
+
self._accumulate(chunk)
|
|
367
|
+
return chunk
|
|
368
|
+
|
|
369
|
+
def close(self) -> None:
|
|
370
|
+
close = getattr(self._stream, "close", None)
|
|
371
|
+
if callable(close):
|
|
372
|
+
try:
|
|
373
|
+
close()
|
|
374
|
+
except Exception:
|
|
375
|
+
pass
|
|
376
|
+
self._finish(None)
|
|
377
|
+
|
|
378
|
+
def __enter__(self) -> "_SyncStreamProxy":
|
|
379
|
+
enter = getattr(self._stream, "__enter__", None)
|
|
380
|
+
if callable(enter):
|
|
381
|
+
try:
|
|
382
|
+
enter()
|
|
383
|
+
except Exception:
|
|
384
|
+
pass
|
|
385
|
+
return self
|
|
386
|
+
|
|
387
|
+
def __exit__(self, *args: Any) -> None:
|
|
388
|
+
self.close()
|
|
389
|
+
|
|
390
|
+
def _finish(self, error: Optional[Exception]) -> None:
|
|
391
|
+
if self._finished:
|
|
392
|
+
return
|
|
393
|
+
self._finished = True
|
|
394
|
+
self._finish_fn(error)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
class _AsyncStreamProxy:
|
|
398
|
+
"""Wraps an async stream, delegating attributes and accumulating chunks."""
|
|
399
|
+
|
|
400
|
+
def __init__(
|
|
401
|
+
self,
|
|
402
|
+
stream: Any,
|
|
403
|
+
accumulate: Callable[[Any], None],
|
|
404
|
+
finish: Callable[[Optional[Exception]], None],
|
|
405
|
+
) -> None:
|
|
406
|
+
self._stream = stream
|
|
407
|
+
self._accumulate = accumulate
|
|
408
|
+
self._finish_fn = finish
|
|
409
|
+
self._finished = False
|
|
410
|
+
|
|
411
|
+
def __getattr__(self, name: str) -> Any:
|
|
412
|
+
return getattr(self._stream, name)
|
|
413
|
+
|
|
414
|
+
def __aiter__(self) -> "_AsyncStreamProxy":
|
|
415
|
+
return self
|
|
416
|
+
|
|
417
|
+
async def __anext__(self) -> Any:
|
|
418
|
+
try:
|
|
419
|
+
chunk = await self._stream.__anext__()
|
|
420
|
+
except StopAsyncIteration:
|
|
421
|
+
self._finish(None)
|
|
422
|
+
raise
|
|
423
|
+
except Exception as exc:
|
|
424
|
+
self._finish(exc)
|
|
425
|
+
raise
|
|
426
|
+
self._accumulate(chunk)
|
|
427
|
+
return chunk
|
|
428
|
+
|
|
429
|
+
async def aclose(self) -> None:
|
|
430
|
+
close = getattr(self._stream, "aclose", None)
|
|
431
|
+
if callable(close):
|
|
432
|
+
try:
|
|
433
|
+
await close()
|
|
434
|
+
except Exception:
|
|
435
|
+
pass
|
|
436
|
+
self._finish(None)
|
|
437
|
+
|
|
438
|
+
async def __aenter__(self) -> "_AsyncStreamProxy":
|
|
439
|
+
enter = getattr(self._stream, "__aenter__", None)
|
|
440
|
+
if callable(enter):
|
|
441
|
+
try:
|
|
442
|
+
await enter()
|
|
443
|
+
except Exception:
|
|
444
|
+
pass
|
|
445
|
+
return self
|
|
446
|
+
|
|
447
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
448
|
+
await self.aclose()
|
|
449
|
+
|
|
450
|
+
def _finish(self, error: Optional[Exception]) -> None:
|
|
451
|
+
if self._finished:
|
|
452
|
+
return
|
|
453
|
+
self._finished = True
|
|
454
|
+
self._finish_fn(error)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _wrap_stream(
|
|
458
|
+
result: Any,
|
|
459
|
+
client: Any,
|
|
460
|
+
provider: str,
|
|
461
|
+
request_body: Dict[str, Any],
|
|
462
|
+
start: float,
|
|
463
|
+
name: Optional[str] = None,
|
|
464
|
+
) -> Any:
|
|
465
|
+
accumulator = _new_accumulator(provider)
|
|
466
|
+
|
|
467
|
+
def accumulate(chunk: Any) -> None:
|
|
468
|
+
_accumulate(provider, accumulator, chunk)
|
|
469
|
+
|
|
470
|
+
def finish(error: Optional[Exception]) -> None:
|
|
471
|
+
if error is not None:
|
|
472
|
+
_record(
|
|
473
|
+
client,
|
|
474
|
+
provider,
|
|
475
|
+
request_body,
|
|
476
|
+
name=name,
|
|
477
|
+
error=str(error),
|
|
478
|
+
error_detail=repr(error),
|
|
479
|
+
latency_ms=_since(start),
|
|
480
|
+
status=_status_for(error),
|
|
481
|
+
)
|
|
482
|
+
return
|
|
483
|
+
_record(
|
|
484
|
+
client,
|
|
485
|
+
provider,
|
|
486
|
+
request_body,
|
|
487
|
+
name=name,
|
|
488
|
+
response=_assembled_response(provider, accumulator),
|
|
489
|
+
latency_ms=_since(start),
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
if hasattr(result, "__anext__"):
|
|
493
|
+
return _AsyncStreamProxy(result, accumulate, finish)
|
|
494
|
+
return _SyncStreamProxy(result, accumulate, finish)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _new_accumulator(provider: str) -> Dict[str, Any]:
|
|
498
|
+
if provider == "anthropic":
|
|
499
|
+
return {
|
|
500
|
+
"model": None,
|
|
501
|
+
"content": "",
|
|
502
|
+
"thinking": "",
|
|
503
|
+
"tool_calls": [],
|
|
504
|
+
"usage": {},
|
|
505
|
+
"finish_reason": None,
|
|
506
|
+
"role": "assistant",
|
|
507
|
+
}
|
|
508
|
+
return {
|
|
509
|
+
"model": None,
|
|
510
|
+
"content": "",
|
|
511
|
+
"thinking": "",
|
|
512
|
+
"tool_calls": [],
|
|
513
|
+
"usage": {},
|
|
514
|
+
"finish_reason": None,
|
|
515
|
+
"role": "assistant",
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _accumulate(provider: str, accumulator: Dict[str, Any], chunk: Any) -> None:
|
|
520
|
+
data = _serialize(chunk) if not isinstance(chunk, dict) else chunk
|
|
521
|
+
if provider == "anthropic":
|
|
522
|
+
_accumulate_anthropic(accumulator, data)
|
|
523
|
+
else:
|
|
524
|
+
_accumulate_openai(accumulator, data)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _accumulate_openai(accumulator: Dict[str, Any], data: Dict[str, Any]) -> None:
|
|
528
|
+
if isinstance(data.get("model"), str) and accumulator["model"] is None:
|
|
529
|
+
accumulator["model"] = data["model"]
|
|
530
|
+
choices = data.get("choices")
|
|
531
|
+
if isinstance(choices, list) and choices:
|
|
532
|
+
choice = choices[0] if isinstance(choices[0], dict) else {}
|
|
533
|
+
delta = choice.get("delta") if isinstance(choice.get("delta"), dict) else {}
|
|
534
|
+
if isinstance(delta.get("content"), str):
|
|
535
|
+
accumulator["content"] += delta["content"]
|
|
536
|
+
if isinstance(delta.get("reasoning_content"), str):
|
|
537
|
+
accumulator["thinking"] += delta["reasoning_content"]
|
|
538
|
+
for tool_call in delta.get("tool_calls") or []:
|
|
539
|
+
if not isinstance(tool_call, dict):
|
|
540
|
+
continue
|
|
541
|
+
index = tool_call.get("index", len(accumulator["tool_calls"]))
|
|
542
|
+
while len(accumulator["tool_calls"]) <= index:
|
|
543
|
+
accumulator["tool_calls"].append({"id": None, "name": "", "arguments": ""})
|
|
544
|
+
entry = accumulator["tool_calls"][index]
|
|
545
|
+
if tool_call.get("id"):
|
|
546
|
+
entry["id"] = tool_call["id"]
|
|
547
|
+
function = tool_call.get("function") if isinstance(tool_call.get("function"), dict) else {}
|
|
548
|
+
if isinstance(function.get("name"), str):
|
|
549
|
+
entry["name"] += function["name"]
|
|
550
|
+
if isinstance(function.get("arguments"), str):
|
|
551
|
+
entry["arguments"] += function["arguments"]
|
|
552
|
+
if choice.get("finish_reason"):
|
|
553
|
+
accumulator["finish_reason"] = choice["finish_reason"]
|
|
554
|
+
usage = data.get("usage")
|
|
555
|
+
if isinstance(usage, dict):
|
|
556
|
+
accumulator["usage"] = usage
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _accumulate_anthropic(accumulator: Dict[str, Any], data: Dict[str, Any]) -> None:
|
|
560
|
+
if isinstance(data.get("model"), str) and accumulator["model"] is None:
|
|
561
|
+
accumulator["model"] = data["model"]
|
|
562
|
+
event_type = data.get("type")
|
|
563
|
+
if event_type == "message_start":
|
|
564
|
+
message = data.get("message") if isinstance(data.get("message"), dict) else {}
|
|
565
|
+
if isinstance(message.get("model"), str):
|
|
566
|
+
accumulator["model"] = message["model"]
|
|
567
|
+
if isinstance(message.get("usage"), dict):
|
|
568
|
+
accumulator["usage"].update(message["usage"])
|
|
569
|
+
elif event_type == "content_block_start":
|
|
570
|
+
block = data.get("content_block") if isinstance(data.get("content_block"), dict) else {}
|
|
571
|
+
if block.get("type") == "tool_use":
|
|
572
|
+
accumulator["tool_calls"].append(
|
|
573
|
+
{"id": block.get("id"), "name": block.get("name") or "", "arguments": ""}
|
|
574
|
+
)
|
|
575
|
+
elif event_type == "content_block_delta":
|
|
576
|
+
delta = data.get("delta") if isinstance(data.get("delta"), dict) else {}
|
|
577
|
+
if delta.get("type") == "text_delta" and isinstance(delta.get("text"), str):
|
|
578
|
+
accumulator["content"] += delta["text"]
|
|
579
|
+
elif delta.get("type") == "thinking_delta" and isinstance(delta.get("thinking"), str):
|
|
580
|
+
accumulator["thinking"] += delta["thinking"]
|
|
581
|
+
elif delta.get("type") == "input_json_delta" and isinstance(delta.get("partial_json"), str):
|
|
582
|
+
if accumulator["tool_calls"]:
|
|
583
|
+
accumulator["tool_calls"][-1]["arguments"] += delta["partial_json"]
|
|
584
|
+
elif event_type == "message_delta":
|
|
585
|
+
delta = data.get("delta") if isinstance(data.get("delta"), dict) else {}
|
|
586
|
+
if delta.get("stop_reason"):
|
|
587
|
+
accumulator["finish_reason"] = delta["stop_reason"]
|
|
588
|
+
if isinstance(data.get("usage"), dict):
|
|
589
|
+
accumulator["usage"].update(data["usage"])
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _assembled_response(provider: str, accumulator: Dict[str, Any]) -> Dict[str, Any]:
|
|
593
|
+
tool_calls = [entry for entry in accumulator["tool_calls"] if entry.get("name")]
|
|
594
|
+
if provider == "anthropic":
|
|
595
|
+
content: List[Dict[str, Any]] = []
|
|
596
|
+
if accumulator["content"]:
|
|
597
|
+
content.append({"type": "text", "text": accumulator["content"]})
|
|
598
|
+
for tool in tool_calls:
|
|
599
|
+
content.append(
|
|
600
|
+
{
|
|
601
|
+
"type": "tool_use",
|
|
602
|
+
"id": tool.get("id") or f"toolu_{uuid.uuid4().hex[:12]}",
|
|
603
|
+
"name": tool["name"],
|
|
604
|
+
"input": _maybe_json(tool.get("arguments")),
|
|
605
|
+
}
|
|
606
|
+
)
|
|
607
|
+
response: Dict[str, Any] = {
|
|
608
|
+
"model": accumulator["model"],
|
|
609
|
+
"role": "assistant",
|
|
610
|
+
"content": content,
|
|
611
|
+
"stop_reason": accumulator["finish_reason"],
|
|
612
|
+
"usage": accumulator["usage"],
|
|
613
|
+
}
|
|
614
|
+
if accumulator["thinking"]:
|
|
615
|
+
response["thinking"] = accumulator["thinking"]
|
|
616
|
+
return response
|
|
617
|
+
message: Dict[str, Any] = {"role": "assistant", "content": accumulator["content"]}
|
|
618
|
+
if accumulator["thinking"]:
|
|
619
|
+
message["reasoning_content"] = accumulator["thinking"]
|
|
620
|
+
if tool_calls:
|
|
621
|
+
message["tool_calls"] = [
|
|
622
|
+
{
|
|
623
|
+
"id": tool.get("id") or f"call_{uuid.uuid4().hex[:12]}",
|
|
624
|
+
"type": "function",
|
|
625
|
+
"function": {"name": tool["name"], "arguments": tool.get("arguments") or "{}"},
|
|
626
|
+
}
|
|
627
|
+
for tool in tool_calls
|
|
628
|
+
]
|
|
629
|
+
return {
|
|
630
|
+
"model": accumulator["model"],
|
|
631
|
+
"choices": [{"index": 0, "message": message, "finish_reason": accumulator["finish_reason"]}],
|
|
632
|
+
"usage": accumulator["usage"],
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def _maybe_json(text: Optional[str]) -> Any:
|
|
637
|
+
if not text:
|
|
638
|
+
return {}
|
|
639
|
+
try:
|
|
640
|
+
return json.loads(text)
|
|
641
|
+
except (TypeError, ValueError):
|
|
642
|
+
return text
|
torrix/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: torrix-sdk
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: Python SDK for Torrix, self-hosted observability for AI agents: record OpenAI and Anthropic calls with no dependencies.
|
|
5
|
+
Author: Torrix
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/TorrixAI/torrix
|
|
8
|
+
Project-URL: Documentation, https://github.com/TorrixAI/torrix/blob/main/docs/integrations/python-sdk.md
|
|
9
|
+
Project-URL: Issues, https://github.com/TorrixAI/torrix/issues
|
|
10
|
+
Keywords: llm,observability,ai-agents,openai,anthropic,tracing,cost,self-hosted
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
15
|
+
Classifier: Topic :: System :: Monitoring
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Provides-Extra: openai
|
|
20
|
+
Requires-Dist: openai>=1.0.0; extra == "openai"
|
|
21
|
+
Provides-Extra: anthropic
|
|
22
|
+
Requires-Dist: anthropic>=0.30.0; extra == "anthropic"
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# Torrix Python SDK
|
|
26
|
+
|
|
27
|
+
Record every OpenAI and Anthropic call your Python app or agent makes in
|
|
28
|
+
[Torrix](https://github.com/TorrixAI/torrix), the self-hosted observability platform for AI agents.
|
|
29
|
+
You get the prompt, response, tokens, cost, latency and tool calls of each call, grouped into
|
|
30
|
+
traces and sessions.
|
|
31
|
+
|
|
32
|
+
- No dependencies of its own. Works with sync, async and streaming clients.
|
|
33
|
+
- Sends data in a background thread, so your app is not slowed down.
|
|
34
|
+
- Your data goes only to your own Torrix server.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install torrix-sdk
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
You also need a running Torrix server. The quickest way:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
docker run -d --name torrix -p 8088:8088 -v torrix_data:/data gettorrixai/torrix:latest
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Open http://localhost:8088, create your account, and copy an API key from **Integrations**.
|
|
49
|
+
|
|
50
|
+
## Use
|
|
51
|
+
|
|
52
|
+
The package installs as `torrix-sdk` and imports as `torrix`.
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
import torrix
|
|
56
|
+
|
|
57
|
+
torrix.init(api_key="trxk_YOUR_KEY", base_url="http://localhost:8088")
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
That is all. Every call made through the installed `openai` and `anthropic` packages is recorded
|
|
61
|
+
from now on.
|
|
62
|
+
|
|
63
|
+
Group calls into one turn and name the agent that made them:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
with torrix.run_context(session_id="chat-7", agent_name="support-bot"):
|
|
67
|
+
answer = client.chat.completions.create(model="gpt-4o-mini", messages=[...])
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Short scripts and serverless functions should call `torrix.flush()` before they exit.
|
|
71
|
+
|
|
72
|
+
## Learn more
|
|
73
|
+
|
|
74
|
+
- [Python SDK guide](https://github.com/TorrixAI/torrix/blob/main/docs/integrations/python-sdk.md)
|
|
75
|
+
- [How Torrix organizes your data](https://github.com/TorrixAI/torrix/blob/main/docs/concepts.md)
|
|
76
|
+
- [Torrix on GitHub](https://github.com/TorrixAI/torrix)
|
|
77
|
+
|
|
78
|
+
MIT licensed.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
torrix/__init__.py,sha256=kKwhoErmXH_fxZuZ5fSd3YI1QCLxm45FyUTSUvHjKJs,5209
|
|
2
|
+
torrix/_client.py,sha256=IuQXKS2iUMmcVex07Z1nS0jZfk0HLubJs62-HmwSiCs,7441
|
|
3
|
+
torrix/_context.py,sha256=Bt6R2v2kzOZ3Dkfl1Xbq_aAHtYmTi2-wY2Mz8uuYg9Y,1989
|
|
4
|
+
torrix/_wrap.py,sha256=tzzbx14iVvclnu5Hs0pKxwZsQVLKX4FPI3cJiHmQxGA,22008
|
|
5
|
+
torrix/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
torrix_sdk-1.1.0.dist-info/licenses/LICENSE,sha256=9AOvIuhmnC9liUXm8O-j7CGzTufXT9E7rRVQs61VHqw,1063
|
|
7
|
+
torrix_sdk-1.1.0.dist-info/METADATA,sha256=CPzex-CgDeQr8-ROHuGvyVFCgPzCueoYFm5Xco-rTH0,2709
|
|
8
|
+
torrix_sdk-1.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
9
|
+
torrix_sdk-1.1.0.dist-info/top_level.txt,sha256=2iagmeW2z45qeI-jFNmSl6oZ36SwRWM-eBIuFsiJqwk,7
|
|
10
|
+
torrix_sdk-1.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Torrix
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
torrix
|