datagol-lens 0.2.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.
- datagol_lens-0.2.0/LICENSE +21 -0
- datagol_lens-0.2.0/PKG-INFO +107 -0
- datagol_lens-0.2.0/README.md +88 -0
- datagol_lens-0.2.0/datagol_lens/__init__.py +17 -0
- datagol_lens-0.2.0/datagol_lens/client.py +285 -0
- datagol_lens-0.2.0/datagol_lens/decorators.py +62 -0
- datagol_lens-0.2.0/datagol_lens/gateway_client.py +114 -0
- datagol_lens-0.2.0/datagol_lens/guardrails.py +137 -0
- datagol_lens-0.2.0/datagol_lens/langchain.py +240 -0
- datagol_lens-0.2.0/datagol_lens/redaction.py +114 -0
- datagol_lens-0.2.0/datagol_lens/tracer.py +310 -0
- datagol_lens-0.2.0/datagol_lens/wrappers.py +400 -0
- datagol_lens-0.2.0/datagol_lens.egg-info/PKG-INFO +107 -0
- datagol_lens-0.2.0/datagol_lens.egg-info/SOURCES.txt +18 -0
- datagol_lens-0.2.0/datagol_lens.egg-info/dependency_links.txt +1 -0
- datagol_lens-0.2.0/datagol_lens.egg-info/requires.txt +4 -0
- datagol_lens-0.2.0/datagol_lens.egg-info/top_level.txt +1 -0
- datagol_lens-0.2.0/pyproject.toml +29 -0
- datagol_lens-0.2.0/setup.cfg +4 -0
- datagol_lens-0.2.0/tests/test_budget_and_usage.py +175 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DataGOL
|
|
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,107 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: datagol-lens
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Python SDK for DataGOL Lens: tracing, cost and budgets for LLM applications
|
|
5
|
+
Author: DataGOL
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://datagol.ai
|
|
8
|
+
Keywords: llm,observability,tracing,langchain,datagol
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Topic :: System :: Monitoring
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Requires-Dist: httpx>=0.27.0
|
|
16
|
+
Provides-Extra: langchain
|
|
17
|
+
Requires-Dist: langchain-core>=0.3; extra == "langchain"
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# datagol-lens
|
|
21
|
+
|
|
22
|
+
Python SDK for [DataGOL Lens](https://datagol.ai): traces, token usage, cost and
|
|
23
|
+
budgets for LLM applications. Wrap your Anthropic or OpenAI client, or attach a
|
|
24
|
+
LangChain callback, and every call shows up in Lens as a trace with its spans.
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install datagol-lens
|
|
28
|
+
pip install "datagol-lens[langchain]" # for the LangChain / LangGraph handler
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Configure
|
|
32
|
+
|
|
33
|
+
| Variable | Description |
|
|
34
|
+
|---|---|
|
|
35
|
+
| `DATAGOL_LENS_API_URL` | Your Lens backend URL (default `http://localhost:8000`) |
|
|
36
|
+
| `DATAGOL_LENS_API_KEY` | A project API key (`dgl_...`), created in Lens for the project you trace into |
|
|
37
|
+
|
|
38
|
+
Both can also be passed to `DatagolLensClient(api_url=..., api_key=...)`.
|
|
39
|
+
|
|
40
|
+
## Trace one call per request
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
import anthropic
|
|
44
|
+
from datagol_lens import DatagolLensClient
|
|
45
|
+
|
|
46
|
+
sdk = DatagolLensClient()
|
|
47
|
+
client = sdk.wrap(anthropic.Anthropic(), project_id="<project-id>", name="support-bot")
|
|
48
|
+
|
|
49
|
+
resp = client.messages.create(
|
|
50
|
+
model="claude-haiku-4-5-20251001",
|
|
51
|
+
messages=[{"role": "user", "content": "What is Python?"}],
|
|
52
|
+
max_tokens=200,
|
|
53
|
+
)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The same works for `openai.OpenAI()`.
|
|
57
|
+
|
|
58
|
+
## Trace a multi-step run
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from datagol_lens import DatagolLensClient, TraceContext
|
|
62
|
+
|
|
63
|
+
sdk = DatagolLensClient()
|
|
64
|
+
|
|
65
|
+
with TraceContext(sdk, "<project-id>", name="research-agent",
|
|
66
|
+
session_id="sess-abc", user_id="user-123") as ctx:
|
|
67
|
+
llm = ctx.wrap(anthropic.Anthropic()) # each call becomes a span
|
|
68
|
+
resp = llm.messages.create(model="claude-haiku-4-5-20251001", max_tokens=300,
|
|
69
|
+
messages=[{"role": "user", "content": "Summarize recent AI news"}])
|
|
70
|
+
ctx.add_span(name="vector_search", span_type="retriever",
|
|
71
|
+
input={"query": "AI news"}, output={"chunks": 2})
|
|
72
|
+
ctx.set_output({"summary": resp.content[0].text})
|
|
73
|
+
# the trace is sent when the block exits
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## LangChain and LangGraph
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from datagol_lens import DatagolLensClient, TraceContext
|
|
80
|
+
from datagol_lens.langchain import DatagolLensCallbackHandler
|
|
81
|
+
|
|
82
|
+
with TraceContext(DatagolLensClient(), "<project-id>", name="langchain-agent") as ctx:
|
|
83
|
+
handler = DatagolLensCallbackHandler(ctx)
|
|
84
|
+
answer = chain.invoke("What is Python?", config={"callbacks": [handler]})
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Every chat-model call is recorded as an `llm` span with its token usage, and every
|
|
88
|
+
tool call as a `tool` span. Attach the same handler to a LangGraph agent's `config`.
|
|
89
|
+
|
|
90
|
+
## Budgets and attribution
|
|
91
|
+
|
|
92
|
+
Pass `company_id`, `agent_key` and `user_id` to `TraceContext` and Lens attributes
|
|
93
|
+
the trace's cost to that company, agent and user. Before a run, check the budgets:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from datagol_lens import BudgetExceeded
|
|
97
|
+
|
|
98
|
+
result = sdk.check_budget("<project-id>", company_id="acme", user_id="u-1", agent_key="support-bot")
|
|
99
|
+
if not result["allowed"]:
|
|
100
|
+
raise BudgetExceeded("over budget", result)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Lens prices each span from its model and token counts, so the SDK only reports usage.
|
|
104
|
+
|
|
105
|
+
## License
|
|
106
|
+
|
|
107
|
+
MIT
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# datagol-lens
|
|
2
|
+
|
|
3
|
+
Python SDK for [DataGOL Lens](https://datagol.ai): traces, token usage, cost and
|
|
4
|
+
budgets for LLM applications. Wrap your Anthropic or OpenAI client, or attach a
|
|
5
|
+
LangChain callback, and every call shows up in Lens as a trace with its spans.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install datagol-lens
|
|
9
|
+
pip install "datagol-lens[langchain]" # for the LangChain / LangGraph handler
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Configure
|
|
13
|
+
|
|
14
|
+
| Variable | Description |
|
|
15
|
+
|---|---|
|
|
16
|
+
| `DATAGOL_LENS_API_URL` | Your Lens backend URL (default `http://localhost:8000`) |
|
|
17
|
+
| `DATAGOL_LENS_API_KEY` | A project API key (`dgl_...`), created in Lens for the project you trace into |
|
|
18
|
+
|
|
19
|
+
Both can also be passed to `DatagolLensClient(api_url=..., api_key=...)`.
|
|
20
|
+
|
|
21
|
+
## Trace one call per request
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
import anthropic
|
|
25
|
+
from datagol_lens import DatagolLensClient
|
|
26
|
+
|
|
27
|
+
sdk = DatagolLensClient()
|
|
28
|
+
client = sdk.wrap(anthropic.Anthropic(), project_id="<project-id>", name="support-bot")
|
|
29
|
+
|
|
30
|
+
resp = client.messages.create(
|
|
31
|
+
model="claude-haiku-4-5-20251001",
|
|
32
|
+
messages=[{"role": "user", "content": "What is Python?"}],
|
|
33
|
+
max_tokens=200,
|
|
34
|
+
)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The same works for `openai.OpenAI()`.
|
|
38
|
+
|
|
39
|
+
## Trace a multi-step run
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from datagol_lens import DatagolLensClient, TraceContext
|
|
43
|
+
|
|
44
|
+
sdk = DatagolLensClient()
|
|
45
|
+
|
|
46
|
+
with TraceContext(sdk, "<project-id>", name="research-agent",
|
|
47
|
+
session_id="sess-abc", user_id="user-123") as ctx:
|
|
48
|
+
llm = ctx.wrap(anthropic.Anthropic()) # each call becomes a span
|
|
49
|
+
resp = llm.messages.create(model="claude-haiku-4-5-20251001", max_tokens=300,
|
|
50
|
+
messages=[{"role": "user", "content": "Summarize recent AI news"}])
|
|
51
|
+
ctx.add_span(name="vector_search", span_type="retriever",
|
|
52
|
+
input={"query": "AI news"}, output={"chunks": 2})
|
|
53
|
+
ctx.set_output({"summary": resp.content[0].text})
|
|
54
|
+
# the trace is sent when the block exits
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## LangChain and LangGraph
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from datagol_lens import DatagolLensClient, TraceContext
|
|
61
|
+
from datagol_lens.langchain import DatagolLensCallbackHandler
|
|
62
|
+
|
|
63
|
+
with TraceContext(DatagolLensClient(), "<project-id>", name="langchain-agent") as ctx:
|
|
64
|
+
handler = DatagolLensCallbackHandler(ctx)
|
|
65
|
+
answer = chain.invoke("What is Python?", config={"callbacks": [handler]})
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Every chat-model call is recorded as an `llm` span with its token usage, and every
|
|
69
|
+
tool call as a `tool` span. Attach the same handler to a LangGraph agent's `config`.
|
|
70
|
+
|
|
71
|
+
## Budgets and attribution
|
|
72
|
+
|
|
73
|
+
Pass `company_id`, `agent_key` and `user_id` to `TraceContext` and Lens attributes
|
|
74
|
+
the trace's cost to that company, agent and user. Before a run, check the budgets:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from datagol_lens import BudgetExceeded
|
|
78
|
+
|
|
79
|
+
result = sdk.check_budget("<project-id>", company_id="acme", user_id="u-1", agent_key="support-bot")
|
|
80
|
+
if not result["allowed"]:
|
|
81
|
+
raise BudgetExceeded("over budget", result)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Lens prices each span from its model and token counts, so the SDK only reports usage.
|
|
85
|
+
|
|
86
|
+
## License
|
|
87
|
+
|
|
88
|
+
MIT
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from datagol_lens.client import DatagolLensClient, LensRequestError, BudgetExceeded
|
|
2
|
+
from datagol_lens.gateway_client import GatewayClient
|
|
3
|
+
from datagol_lens.tracer import TraceContext, estimate_cost
|
|
4
|
+
from datagol_lens.decorators import trace, trace_llm_call
|
|
5
|
+
from datagol_lens.wrappers import TracedAnthropicClient, TracedOpenAIClient, TracedLiteLLMCompletion
|
|
6
|
+
from datagol_lens.redaction import Redactor, RedactionRule, built_in_rule
|
|
7
|
+
from datagol_lens.guardrails import Guardrails, GuardrailRule, GuardrailViolation, built_in_jailbreak_rule
|
|
8
|
+
from datagol_lens.langchain import DatagolLensCallbackHandler
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"DatagolLensClient", "LensRequestError", "BudgetExceeded", "GatewayClient", "TraceContext", "estimate_cost",
|
|
12
|
+
"trace", "trace_llm_call",
|
|
13
|
+
"TracedAnthropicClient", "TracedOpenAIClient", "TracedLiteLLMCompletion",
|
|
14
|
+
"Redactor", "RedactionRule", "built_in_rule",
|
|
15
|
+
"Guardrails", "GuardrailRule", "GuardrailViolation", "built_in_jailbreak_rule",
|
|
16
|
+
"DatagolLensCallbackHandler",
|
|
17
|
+
]
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import atexit
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import queue
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
import httpx
|
|
8
|
+
from typing import Any, Dict, List, Optional, Union
|
|
9
|
+
from datagol_lens.redaction import Redactor
|
|
10
|
+
from datagol_lens.guardrails import Guardrails
|
|
11
|
+
|
|
12
|
+
# Provider -> env var that Anthropic/OpenAI/litellm SDKs read their API key from by default.
|
|
13
|
+
# Finished traces waiting for the background sender; beyond this they are dropped
|
|
14
|
+
# rather than holding memory while Lens is unreachable.
|
|
15
|
+
_MAX_QUEUE = 1000
|
|
16
|
+
|
|
17
|
+
_PROVIDER_ENV_VARS = {
|
|
18
|
+
"anthropic": "ANTHROPIC_API_KEY",
|
|
19
|
+
"openai": "OPENAI_API_KEY",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class LensRequestError(Exception):
|
|
24
|
+
"""A Lens API call failed: network error, timeout, non-2xx status, or bad response body.
|
|
25
|
+
|
|
26
|
+
``status_code`` is set when the server answered with a non-2xx status.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, message: str, status_code: Optional[int] = None):
|
|
30
|
+
super().__init__(message)
|
|
31
|
+
self.status_code = status_code
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class BudgetExceeded(Exception):
|
|
35
|
+
"""Raised by callers when a budget check blocks a request.
|
|
36
|
+
|
|
37
|
+
``result`` is the full ``check_budget`` response and ``budget`` its ``blocked_by``
|
|
38
|
+
BudgetStatus (or None, e.g. when the check itself failed and the caller failed closed).
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, message: str, result: Optional[Dict[str, Any]] = None):
|
|
42
|
+
super().__init__(message)
|
|
43
|
+
self.result = result
|
|
44
|
+
self.budget = (result or {}).get("blocked_by")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class DatagolLensClient:
|
|
48
|
+
"""HTTP client for the Datagol Lens API."""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
api_url: Optional[str] = None,
|
|
53
|
+
api_key: Optional[str] = None,
|
|
54
|
+
virtual_key: Optional[str] = None,
|
|
55
|
+
auto_configure_provider_env: bool = True,
|
|
56
|
+
redactor: Optional[Redactor] = None,
|
|
57
|
+
guardrails: Optional[Guardrails] = None,
|
|
58
|
+
background: bool = True,
|
|
59
|
+
):
|
|
60
|
+
"""``background`` (default) sends finished traces from a daemon thread so the
|
|
61
|
+
traced code never waits on Lens; call :meth:`flush` before a short-lived
|
|
62
|
+
process exits (an exit hook also tries). Pass False to send inline, for
|
|
63
|
+
example in a serverless function that is frozen right after returning."""
|
|
64
|
+
self.background = background
|
|
65
|
+
self._queue: "queue.Queue[tuple[str, bytes]]" = queue.Queue(maxsize=_MAX_QUEUE)
|
|
66
|
+
self._sender: Optional[threading.Thread] = None
|
|
67
|
+
self._sender_lock = threading.Lock()
|
|
68
|
+
self.dropped_traces = 0
|
|
69
|
+
self.failed_traces = 0
|
|
70
|
+
self.api_url = (api_url or os.getenv("DATAGOL_LENS_API_URL", "http://localhost:8000")).rstrip("/")
|
|
71
|
+
self.api_key = api_key or os.getenv("DATAGOL_LENS_API_KEY", "")
|
|
72
|
+
self._client = httpx.Client(
|
|
73
|
+
base_url=self.api_url,
|
|
74
|
+
headers={"Authorization": f"Bearer {self.api_key}"} if self.api_key else {},
|
|
75
|
+
timeout=10.0,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Set so every TraceContext created from this client — including via wrap()/@trace,
|
|
79
|
+
# which don't take a redactor/virtual_key_id argument themselves — picks it up automatically.
|
|
80
|
+
self.redactor = redactor
|
|
81
|
+
self.guardrails = guardrails
|
|
82
|
+
self.virtual_key_id: Optional[str] = None
|
|
83
|
+
self.resolved_provider: Optional[str] = None
|
|
84
|
+
self.resolved_allowed_models: Optional[List[str]] = None
|
|
85
|
+
self._prices: Dict[str, Optional[Dict[str, Any]]] = {}
|
|
86
|
+
|
|
87
|
+
virtual_key = virtual_key or os.getenv("DATAGOL_LENS_VIRTUAL_KEY", "")
|
|
88
|
+
if virtual_key:
|
|
89
|
+
self.activate_virtual_key(virtual_key, auto_configure_env=auto_configure_provider_env)
|
|
90
|
+
|
|
91
|
+
def post_trace(self, project_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
92
|
+
resp = self._client.post(f"/api/v1/projects/{project_id}/traces", json=payload)
|
|
93
|
+
resp.raise_for_status()
|
|
94
|
+
return resp.json()
|
|
95
|
+
|
|
96
|
+
def enqueue_trace(self, project_id: str, payload: dict[str, Any]) -> None:
|
|
97
|
+
"""Send a finished trace without blocking the caller (or inline when
|
|
98
|
+
``background=False``). Never raises. A full queue drops the trace and
|
|
99
|
+
counts it in ``dropped_traces``."""
|
|
100
|
+
# Serialised here, in the caller's thread, so later changes to the caller's
|
|
101
|
+
# dicts can't alter what gets sent.
|
|
102
|
+
try:
|
|
103
|
+
body = json.dumps(payload).encode()
|
|
104
|
+
except (TypeError, ValueError):
|
|
105
|
+
self.failed_traces += 1
|
|
106
|
+
return
|
|
107
|
+
if not self.background:
|
|
108
|
+
self._send(project_id, body)
|
|
109
|
+
return
|
|
110
|
+
self._ensure_sender()
|
|
111
|
+
try:
|
|
112
|
+
self._queue.put_nowait((project_id, body))
|
|
113
|
+
except queue.Full:
|
|
114
|
+
self.dropped_traces += 1
|
|
115
|
+
|
|
116
|
+
def flush(self, timeout: float = 5.0) -> bool:
|
|
117
|
+
"""Wait for queued traces to be sent. True if the queue drained in time."""
|
|
118
|
+
deadline = time.monotonic() + timeout
|
|
119
|
+
while self._queue.unfinished_tasks:
|
|
120
|
+
if time.monotonic() > deadline:
|
|
121
|
+
return False
|
|
122
|
+
time.sleep(0.01)
|
|
123
|
+
return True
|
|
124
|
+
|
|
125
|
+
def _send(self, project_id: str, body: bytes) -> None:
|
|
126
|
+
# The trace carries its own id, so a retry after a transport failure can't
|
|
127
|
+
# double-write; one short retry covers a dropped connection or a 5xx blip.
|
|
128
|
+
for attempt in range(2):
|
|
129
|
+
try:
|
|
130
|
+
resp = self._client.post(f"/api/v1/projects/{project_id}/traces", content=body,
|
|
131
|
+
headers={"Content-Type": "application/json"})
|
|
132
|
+
resp.raise_for_status()
|
|
133
|
+
return
|
|
134
|
+
except Exception:
|
|
135
|
+
if attempt == 0:
|
|
136
|
+
time.sleep(0.2)
|
|
137
|
+
self.failed_traces += 1
|
|
138
|
+
|
|
139
|
+
def _ensure_sender(self) -> None:
|
|
140
|
+
if self._sender is not None and self._sender.is_alive():
|
|
141
|
+
return
|
|
142
|
+
with self._sender_lock:
|
|
143
|
+
if self._sender is None or not self._sender.is_alive():
|
|
144
|
+
if self._sender is None:
|
|
145
|
+
atexit.register(self.flush)
|
|
146
|
+
self._sender = threading.Thread(target=self._drain, name="datagol-lens-sender", daemon=True)
|
|
147
|
+
self._sender.start()
|
|
148
|
+
|
|
149
|
+
def _drain(self) -> None:
|
|
150
|
+
while True:
|
|
151
|
+
project_id, body = self._queue.get()
|
|
152
|
+
try:
|
|
153
|
+
self._send(project_id, body)
|
|
154
|
+
finally:
|
|
155
|
+
self._queue.task_done()
|
|
156
|
+
|
|
157
|
+
def resolve_price(self, model: str) -> Optional[Dict[str, Any]]:
|
|
158
|
+
"""Per-token USD rates for ``model`` from the server's price table
|
|
159
|
+
(``GET /api/v1/pricing/{model}``), looked up once per model per client.
|
|
160
|
+
None when the server is unreachable or doesn't know the model, in which
|
|
161
|
+
case the tracer falls back to its builtin table."""
|
|
162
|
+
if model in self._prices:
|
|
163
|
+
return self._prices[model]
|
|
164
|
+
price = None
|
|
165
|
+
try:
|
|
166
|
+
resp = self._client.get(f"/api/v1/pricing/{model}")
|
|
167
|
+
if resp.status_code == 200:
|
|
168
|
+
price = resp.json()
|
|
169
|
+
except Exception:
|
|
170
|
+
price = None
|
|
171
|
+
self._prices[model] = price
|
|
172
|
+
return price
|
|
173
|
+
|
|
174
|
+
def post_feedback(self, project_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
175
|
+
resp = self._client.post(f"/api/v1/projects/{project_id}/feedback", json=payload)
|
|
176
|
+
resp.raise_for_status()
|
|
177
|
+
return resp.json()
|
|
178
|
+
|
|
179
|
+
def _request_json(self, method: str, path: str, timeout: float, **kwargs) -> Dict[str, Any]:
|
|
180
|
+
"""Send a request and return its JSON body, raising :class:`LensRequestError` on any
|
|
181
|
+
network error, timeout, non-2xx status, or non-JSON body."""
|
|
182
|
+
try:
|
|
183
|
+
resp = self._client.request(method, path, timeout=timeout, **kwargs)
|
|
184
|
+
resp.raise_for_status()
|
|
185
|
+
return resp.json()
|
|
186
|
+
except httpx.HTTPStatusError as exc:
|
|
187
|
+
raise LensRequestError(
|
|
188
|
+
f"{method} {path} returned {exc.response.status_code}", exc.response.status_code
|
|
189
|
+
) from exc
|
|
190
|
+
except (httpx.HTTPError, ValueError) as exc:
|
|
191
|
+
raise LensRequestError(f"{method} {path} failed: {exc}") from exc
|
|
192
|
+
|
|
193
|
+
def check_budget(self, project_id: str, company_id: str, user_id: Optional[str] = None,
|
|
194
|
+
agent_key: Optional[str] = None, timeout: float = 2.0) -> Dict[str, Any]:
|
|
195
|
+
"""Check whether a company/agent/user is within its Lens budgets.
|
|
196
|
+
|
|
197
|
+
Returns ``{"allowed", "decision", "blocked_by", "warnings"}``. Raises
|
|
198
|
+
:class:`LensRequestError` on network errors, timeouts, and non-2xx responses so the
|
|
199
|
+
caller can fail closed.
|
|
200
|
+
"""
|
|
201
|
+
return self._request_json(
|
|
202
|
+
"POST", f"/api/v1/projects/{project_id}/budget/check", timeout,
|
|
203
|
+
json={"company_id": company_id, "user_id": user_id, "agent_key": agent_key},
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
def get_enforced_companies(self, project_id: str, timeout: float = 2.0) -> Dict[str, Any]:
|
|
207
|
+
"""Return ``{"company_ids": [...], "version": str}`` — the DataGOL company ids with
|
|
208
|
+
budget enforcement on. Raises :class:`LensRequestError` on failure."""
|
|
209
|
+
return self._request_json(
|
|
210
|
+
"GET", f"/api/v1/projects/{project_id}/budget/enforced-companies", timeout,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
def resolve_virtual_key(self, token: str, model: Optional[str] = None) -> dict[str, Any]:
|
|
214
|
+
"""Resolve a virtual key ("vk_...") to its real provider key + metadata.
|
|
215
|
+
|
|
216
|
+
The real key is returned once per call and never logged by the SDK — callers
|
|
217
|
+
should generally use :meth:`activate_virtual_key` instead of calling this directly.
|
|
218
|
+
"""
|
|
219
|
+
resp = self._client.post("/api/v1/virtual-keys/resolve", json={"token": token, "model": model})
|
|
220
|
+
resp.raise_for_status()
|
|
221
|
+
return resp.json()
|
|
222
|
+
|
|
223
|
+
def activate_virtual_key(self, token: str, auto_configure_env: bool = True) -> None:
|
|
224
|
+
"""Resolve a virtual key and (by default) inject the real provider key into the
|
|
225
|
+
environment variable that provider's own SDK reads, so ``anthropic.Anthropic()``,
|
|
226
|
+
``openai.OpenAI()``, or ``litellm.completion(...)`` work without ever touching
|
|
227
|
+
the real secret in application code.
|
|
228
|
+
"""
|
|
229
|
+
result = self.resolve_virtual_key(token)
|
|
230
|
+
self.virtual_key_id = result["id"]
|
|
231
|
+
self.resolved_provider = result["provider"]
|
|
232
|
+
self.resolved_allowed_models = result.get("allowed_models")
|
|
233
|
+
if auto_configure_env:
|
|
234
|
+
env_var = _PROVIDER_ENV_VARS.get(self.resolved_provider)
|
|
235
|
+
if env_var:
|
|
236
|
+
os.environ[env_var] = result["api_key"]
|
|
237
|
+
|
|
238
|
+
def gateway(self, virtual_key: Optional[str] = None) -> "GatewayClient":
|
|
239
|
+
"""Return a :class:`~datagol_lens.gateway_client.GatewayClient` (the
|
|
240
|
+
LiteLLM/OpenAI-compatible ``/v1/...`` gateway) sharing this client's ``api_url``.
|
|
241
|
+
"""
|
|
242
|
+
from datagol_lens.gateway_client import GatewayClient
|
|
243
|
+
return GatewayClient(api_url=self.api_url, virtual_key=virtual_key)
|
|
244
|
+
|
|
245
|
+
def get_redaction_rules(self, project_id: str) -> List[Dict[str, Any]]:
|
|
246
|
+
"""Fetch active PII/sensitive-data redaction rules configured for a project."""
|
|
247
|
+
resp = self._client.get(f"/api/v1/projects/{project_id}/redaction-rules")
|
|
248
|
+
resp.raise_for_status()
|
|
249
|
+
return resp.json()
|
|
250
|
+
|
|
251
|
+
def get_guardrails(self, project_id: str) -> List[Dict[str, Any]]:
|
|
252
|
+
"""Fetch active prompt guardrail rules configured for a project."""
|
|
253
|
+
resp = self._client.get(f"/api/v1/projects/{project_id}/guardrails")
|
|
254
|
+
resp.raise_for_status()
|
|
255
|
+
return resp.json()
|
|
256
|
+
|
|
257
|
+
def wrap(self, llm_client, project_id: str,
|
|
258
|
+
name: Optional[str] = None,
|
|
259
|
+
tags: Optional[Union[List[str], Dict[str, str]]] = None,
|
|
260
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
261
|
+
guardrails: Optional[Guardrails] = None):
|
|
262
|
+
"""Wrap an LLM client so each call automatically creates a trace.
|
|
263
|
+
|
|
264
|
+
If guardrails are configured (via ``guardrails=`` here or on the client), every
|
|
265
|
+
prompt is validated against them before it reaches the LLM; a violation of a
|
|
266
|
+
``block``-action rule raises :class:`GuardrailViolation` and the call never happens.
|
|
267
|
+
|
|
268
|
+
Usage::
|
|
269
|
+
|
|
270
|
+
client = sdk.wrap(anthropic.Anthropic(), project_id="proj-123")
|
|
271
|
+
client.messages.create(model="claude-haiku-4-5-20251001", messages=[...], max_tokens=100)
|
|
272
|
+
"""
|
|
273
|
+
from datagol_lens.wrappers import wrap_for_trace
|
|
274
|
+
return wrap_for_trace(llm_client, self, project_id, name=name, tags=tags, metadata=metadata,
|
|
275
|
+
guardrails=guardrails)
|
|
276
|
+
|
|
277
|
+
def close(self):
|
|
278
|
+
self.flush()
|
|
279
|
+
self._client.close()
|
|
280
|
+
|
|
281
|
+
def __enter__(self):
|
|
282
|
+
return self
|
|
283
|
+
|
|
284
|
+
def __exit__(self, *_):
|
|
285
|
+
self.close()
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
import time
|
|
3
|
+
from typing import Any, Callable, Dict, List, Optional, Union
|
|
4
|
+
from datagol_lens.client import DatagolLensClient
|
|
5
|
+
from datagol_lens.tracer import TraceContext, estimate_cost
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def trace(
|
|
9
|
+
project_id: str,
|
|
10
|
+
name: Optional[str] = None,
|
|
11
|
+
tags: Optional[Union[List[str], Dict[str, str]]] = None,
|
|
12
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
13
|
+
client: Optional[DatagolLensClient] = None,
|
|
14
|
+
):
|
|
15
|
+
"""Decorator: wraps a function in a TraceContext and ships the trace on completion.
|
|
16
|
+
|
|
17
|
+
Usage::
|
|
18
|
+
|
|
19
|
+
@trace(project_id="proj-123", tags=["chat"])
|
|
20
|
+
def answer_question(question: str) -> str:
|
|
21
|
+
...
|
|
22
|
+
"""
|
|
23
|
+
def decorator(fn: Callable) -> Callable:
|
|
24
|
+
@functools.wraps(fn)
|
|
25
|
+
def wrapper(*args, **kwargs):
|
|
26
|
+
_client = client or DatagolLensClient()
|
|
27
|
+
trace_name = name or fn.__qualname__
|
|
28
|
+
ctx = TraceContext(
|
|
29
|
+
client=_client,
|
|
30
|
+
project_id=project_id,
|
|
31
|
+
name=trace_name,
|
|
32
|
+
input={"args": list(args), "kwargs": kwargs},
|
|
33
|
+
tags=tags or [],
|
|
34
|
+
metadata=metadata or {},
|
|
35
|
+
)
|
|
36
|
+
with ctx:
|
|
37
|
+
result = fn(*args, **kwargs)
|
|
38
|
+
ctx.set_output({"result": result})
|
|
39
|
+
return result
|
|
40
|
+
return wrapper
|
|
41
|
+
return decorator
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def trace_llm_call(
|
|
45
|
+
ctx: TraceContext,
|
|
46
|
+
model: str,
|
|
47
|
+
messages: list[dict],
|
|
48
|
+
response_text: str,
|
|
49
|
+
prompt_tokens: int,
|
|
50
|
+
completion_tokens: int,
|
|
51
|
+
span_name: str = "llm_call",
|
|
52
|
+
):
|
|
53
|
+
"""Helper to record a single LLM call as a span inside an existing TraceContext."""
|
|
54
|
+
ctx.add_span(
|
|
55
|
+
name=span_name,
|
|
56
|
+
span_type="llm",
|
|
57
|
+
input={"messages": messages},
|
|
58
|
+
output={"content": response_text},
|
|
59
|
+
model=model,
|
|
60
|
+
prompt_tokens=prompt_tokens,
|
|
61
|
+
completion_tokens=completion_tokens,
|
|
62
|
+
)
|