torrix-sdk 1.1.0__tar.gz

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,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,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,54 @@
1
+ # Torrix Python SDK
2
+
3
+ Record every OpenAI and Anthropic call your Python app or agent makes in
4
+ [Torrix](https://github.com/TorrixAI/torrix), the self-hosted observability platform for AI agents.
5
+ You get the prompt, response, tokens, cost, latency and tool calls of each call, grouped into
6
+ traces and sessions.
7
+
8
+ - No dependencies of its own. Works with sync, async and streaming clients.
9
+ - Sends data in a background thread, so your app is not slowed down.
10
+ - Your data goes only to your own Torrix server.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pip install torrix-sdk
16
+ ```
17
+
18
+ You also need a running Torrix server. The quickest way:
19
+
20
+ ```bash
21
+ docker run -d --name torrix -p 8088:8088 -v torrix_data:/data gettorrixai/torrix:latest
22
+ ```
23
+
24
+ Open http://localhost:8088, create your account, and copy an API key from **Integrations**.
25
+
26
+ ## Use
27
+
28
+ The package installs as `torrix-sdk` and imports as `torrix`.
29
+
30
+ ```python
31
+ import torrix
32
+
33
+ torrix.init(api_key="trxk_YOUR_KEY", base_url="http://localhost:8088")
34
+ ```
35
+
36
+ That is all. Every call made through the installed `openai` and `anthropic` packages is recorded
37
+ from now on.
38
+
39
+ Group calls into one turn and name the agent that made them:
40
+
41
+ ```python
42
+ with torrix.run_context(session_id="chat-7", agent_name="support-bot"):
43
+ answer = client.chat.completions.create(model="gpt-4o-mini", messages=[...])
44
+ ```
45
+
46
+ Short scripts and serverless functions should call `torrix.flush()` before they exit.
47
+
48
+ ## Learn more
49
+
50
+ - [Python SDK guide](https://github.com/TorrixAI/torrix/blob/main/docs/integrations/python-sdk.md)
51
+ - [How Torrix organizes your data](https://github.com/TorrixAI/torrix/blob/main/docs/concepts.md)
52
+ - [Torrix on GitHub](https://github.com/TorrixAI/torrix)
53
+
54
+ MIT licensed.
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "torrix-sdk"
7
+ version = "1.1.0"
8
+ description = "Python SDK for Torrix, self-hosted observability for AI agents: record OpenAI and Anthropic calls with no dependencies."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Torrix" }]
13
+ keywords = ["llm", "observability", "ai-agents", "openai", "anthropic", "tracing", "cost", "self-hosted"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Operating System :: OS Independent",
18
+ "Topic :: Software Development :: Libraries",
19
+ "Topic :: System :: Monitoring",
20
+ ]
21
+ dependencies = []
22
+
23
+ [project.optional-dependencies]
24
+ openai = ["openai>=1.0.0"]
25
+ anthropic = ["anthropic>=0.30.0"]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/TorrixAI/torrix"
29
+ Documentation = "https://github.com/TorrixAI/torrix/blob/main/docs/integrations/python-sdk.md"
30
+ Issues = "https://github.com/TorrixAI/torrix/issues"
31
+
32
+ [tool.setuptools.packages.find]
33
+ include = ["torrix*"]
34
+
35
+ [tool.setuptools.package-data]
36
+ torrix = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,79 @@
1
+ """Run context linkage, checked against a local capture server (stdlib only)."""
2
+
3
+ import json
4
+ import threading
5
+ import unittest
6
+ from http.server import BaseHTTPRequestHandler, HTTPServer
7
+ from typing import Any, Dict, List
8
+
9
+ from torrix import run_context
10
+ from torrix._client import TorrixClient
11
+
12
+
13
+ class _Capture(BaseHTTPRequestHandler):
14
+ records: List[Dict[str, Any]] = []
15
+
16
+ def do_POST(self) -> None: # noqa: N802 (http.server naming)
17
+ length = int(self.headers.get("content-length", "0"))
18
+ body = json.loads(self.rfile.read(length) or b"{}")
19
+ batch = body.get("records", body if isinstance(body, list) else [])
20
+ _Capture.records.extend(batch)
21
+ payload = json.dumps({"accepted": len(batch), "ids": [r.get("id") for r in batch]}).encode()
22
+ self.send_response(200)
23
+ self.send_header("content-type", "application/json")
24
+ self.send_header("content-length", str(len(payload)))
25
+ self.end_headers()
26
+ self.wfile.write(payload)
27
+
28
+ def log_message(self, *_: Any) -> None:
29
+ return
30
+
31
+
32
+ class RunContextTest(unittest.TestCase):
33
+ @classmethod
34
+ def setUpClass(cls) -> None:
35
+ cls.server = HTTPServer(("127.0.0.1", 0), _Capture)
36
+ threading.Thread(target=cls.server.serve_forever, daemon=True).start()
37
+ cls.client = TorrixClient(f"http://127.0.0.1:{cls.server.server_port}", "trxk_test", background=False)
38
+
39
+ @classmethod
40
+ def tearDownClass(cls) -> None:
41
+ cls.server.shutdown()
42
+
43
+ def setUp(self) -> None:
44
+ _Capture.records.clear()
45
+
46
+ def test_sequential_calls_are_siblings(self) -> None:
47
+ with run_context(session_id="ses_1", agent_name="planner", name="plan"):
48
+ self.client.ingest({"id": "first"}, wait=True)
49
+ self.client.ingest({"id": "second"}, wait=True)
50
+ first, second = _Capture.records
51
+ self.assertEqual(first["session_id"], "ses_1")
52
+ self.assertEqual(first["agent_name"], "planner")
53
+ self.assertEqual(first["name"], "plan")
54
+ self.assertNotIn("parent_run_id", first)
55
+ self.assertNotIn("parent_run_id", second)
56
+ self.assertEqual(first["trace_id"], second["trace_id"])
57
+
58
+ def test_nested_context_hangs_under_the_call_that_started_it(self) -> None:
59
+ with run_context(agent_name="planner"):
60
+ self.client.ingest({"id": "plan"}, wait=True)
61
+ for agent in ("search", "write"):
62
+ with run_context(agent_name=agent):
63
+ self.client.ingest({"id": f"{agent}-1"}, wait=True)
64
+ self.client.ingest({"id": f"{agent}-2"}, wait=True)
65
+ self.client.ingest({}, wait=True)
66
+ by_id = {r["id"]: r for r in _Capture.records}
67
+ self.assertEqual(by_id["search-1"]["parent_run_id"], "plan")
68
+ self.assertEqual(by_id["search-2"]["parent_run_id"], "plan")
69
+ self.assertEqual(by_id["search-1"]["agent_name"], "search")
70
+ # Each subagent context opens under the planner's latest call, not under its sibling.
71
+ self.assertEqual(by_id["write-1"]["parent_run_id"], "plan")
72
+ last = _Capture.records[-1]
73
+ self.assertTrue(last["id"])
74
+ self.assertNotIn("parent_run_id", last)
75
+ self.assertEqual(len({r["trace_id"] for r in _Capture.records}), 1)
76
+
77
+
78
+ if __name__ == "__main__":
79
+ unittest.main()
@@ -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()
@@ -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
@@ -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)