agentwatch-dev 0.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.
- agentwatch_dev-0.1.0/.gitignore +12 -0
- agentwatch_dev-0.1.0/LICENSE +21 -0
- agentwatch_dev-0.1.0/PKG-INFO +108 -0
- agentwatch_dev-0.1.0/README.md +89 -0
- agentwatch_dev-0.1.0/agentwatch/__init__.py +74 -0
- agentwatch_dev-0.1.0/agentwatch/_extract.py +105 -0
- agentwatch_dev-0.1.0/agentwatch/_transport.py +38 -0
- agentwatch_dev-0.1.0/agentwatch/client.py +283 -0
- agentwatch_dev-0.1.0/pyproject.toml +42 -0
- agentwatch_dev-0.1.0/tests/test_sdk.py +196 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AgentWatch
|
|
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 DEALING IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentwatch-dev
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Predict when production AI agents degrade — before they visibly fail. Python SDK.
|
|
5
|
+
Project-URL: Homepage, https://agentwatch.dev
|
|
6
|
+
Author: AgentWatch
|
|
7
|
+
License: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: ai-agents,cerebras,drift,groq,llm,monitoring,observability,openai
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Requires-Python: >=3.8
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
16
|
+
Provides-Extra: openai
|
|
17
|
+
Requires-Dist: openai>=1.0.0; extra == 'openai'
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# AgentWatch — Python SDK
|
|
21
|
+
|
|
22
|
+
**Predict when production AI agents are degrading — before they visibly fail.**
|
|
23
|
+
|
|
24
|
+
AgentWatch watches your agent's traces and runs statistical change-point
|
|
25
|
+
detection (CUSUM/EWMA), an LLM-judge correctness gate, and semantic-drift scoring
|
|
26
|
+
**server-side** to catch quality collapse *before* your error rate moves. This SDK
|
|
27
|
+
just captures traces and ships them — it has **zero required dependencies** and
|
|
28
|
+
never adds latency to or raises errors in your agent's request path.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install agentwatch-dev
|
|
34
|
+
# optional, only for instrument_openai():
|
|
35
|
+
pip install "agentwatch-dev[openai]"
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The distribution is named `agentwatch-dev` (after agentwatch.dev); the import name
|
|
39
|
+
is just `agentwatch`.
|
|
40
|
+
|
|
41
|
+
## Configure
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
import agentwatch
|
|
45
|
+
agentwatch.configure(api_key="aw_live_...", agent_id="<agent-uuid>")
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Self-hosting? Pass `endpoint="https://your-host"` to `configure(...)`.
|
|
49
|
+
|
|
50
|
+
## Option A — auto-instrument (one line)
|
|
51
|
+
|
|
52
|
+
Works for **any OpenAI-compatible client** — OpenAI, Groq, Cerebras, Together,
|
|
53
|
+
vLLM — because they all use the `openai` client with a different `base_url`.
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from openai import OpenAI
|
|
57
|
+
|
|
58
|
+
client = OpenAI(base_url="https://api.groq.com/openai/v1", api_key=GROQ_API_KEY)
|
|
59
|
+
agentwatch.instrument_openai(client)
|
|
60
|
+
|
|
61
|
+
# Every chat completion from here on is traced automatically:
|
|
62
|
+
client.chat.completions.create(
|
|
63
|
+
model="llama-3.3-70b-versatile",
|
|
64
|
+
messages=[{"role": "user", "content": "Refund order #47829"}],
|
|
65
|
+
)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Option B — wrap your agent function
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
@agentwatch.watch()
|
|
72
|
+
def run_agent(query: str) -> str:
|
|
73
|
+
...
|
|
74
|
+
return answer
|
|
75
|
+
|
|
76
|
+
# async is supported too:
|
|
77
|
+
@agentwatch.watch()
|
|
78
|
+
async def run_agent_async(query: str): ...
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`watch()` captures the first argument as the input and the return value as the
|
|
82
|
+
output. If the return value is an OpenAI-shaped response, it also extracts the
|
|
83
|
+
model, token counts, and tool names automatically.
|
|
84
|
+
|
|
85
|
+
## Option C — manual trace
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
client = agentwatch.AgentWatchClient(api_key="aw_live_...", agent_id="<uuid>")
|
|
89
|
+
client.trace(
|
|
90
|
+
input="Refund order #47829",
|
|
91
|
+
output="I've issued the refund…",
|
|
92
|
+
model="llama-3.3-70b-versatile",
|
|
93
|
+
latency_ms=812,
|
|
94
|
+
prompt_tokens=220, completion_tokens=180, total_tokens=400,
|
|
95
|
+
tools_used=["search_kb", "issue_refund"],
|
|
96
|
+
)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Notes
|
|
100
|
+
|
|
101
|
+
- **Fire-and-forget:** traces are POSTed on a daemon thread; delivery failures are
|
|
102
|
+
swallowed so monitoring can never break your agent.
|
|
103
|
+
- **Metadata:** attach tags via `configure(..., metadata={"env": "prod"})` or
|
|
104
|
+
per-call `@agentwatch.watch(metadata={"version": "2.1"})`.
|
|
105
|
+
- **Streaming** (`stream=True`) responses aren't fully captured yet — the trace is
|
|
106
|
+
still recorded, but token/output extraction may be partial.
|
|
107
|
+
|
|
108
|
+
MIT licensed.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# AgentWatch — Python SDK
|
|
2
|
+
|
|
3
|
+
**Predict when production AI agents are degrading — before they visibly fail.**
|
|
4
|
+
|
|
5
|
+
AgentWatch watches your agent's traces and runs statistical change-point
|
|
6
|
+
detection (CUSUM/EWMA), an LLM-judge correctness gate, and semantic-drift scoring
|
|
7
|
+
**server-side** to catch quality collapse *before* your error rate moves. This SDK
|
|
8
|
+
just captures traces and ships them — it has **zero required dependencies** and
|
|
9
|
+
never adds latency to or raises errors in your agent's request path.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install agentwatch-dev
|
|
15
|
+
# optional, only for instrument_openai():
|
|
16
|
+
pip install "agentwatch-dev[openai]"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The distribution is named `agentwatch-dev` (after agentwatch.dev); the import name
|
|
20
|
+
is just `agentwatch`.
|
|
21
|
+
|
|
22
|
+
## Configure
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
import agentwatch
|
|
26
|
+
agentwatch.configure(api_key="aw_live_...", agent_id="<agent-uuid>")
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Self-hosting? Pass `endpoint="https://your-host"` to `configure(...)`.
|
|
30
|
+
|
|
31
|
+
## Option A — auto-instrument (one line)
|
|
32
|
+
|
|
33
|
+
Works for **any OpenAI-compatible client** — OpenAI, Groq, Cerebras, Together,
|
|
34
|
+
vLLM — because they all use the `openai` client with a different `base_url`.
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from openai import OpenAI
|
|
38
|
+
|
|
39
|
+
client = OpenAI(base_url="https://api.groq.com/openai/v1", api_key=GROQ_API_KEY)
|
|
40
|
+
agentwatch.instrument_openai(client)
|
|
41
|
+
|
|
42
|
+
# Every chat completion from here on is traced automatically:
|
|
43
|
+
client.chat.completions.create(
|
|
44
|
+
model="llama-3.3-70b-versatile",
|
|
45
|
+
messages=[{"role": "user", "content": "Refund order #47829"}],
|
|
46
|
+
)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Option B — wrap your agent function
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
@agentwatch.watch()
|
|
53
|
+
def run_agent(query: str) -> str:
|
|
54
|
+
...
|
|
55
|
+
return answer
|
|
56
|
+
|
|
57
|
+
# async is supported too:
|
|
58
|
+
@agentwatch.watch()
|
|
59
|
+
async def run_agent_async(query: str): ...
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`watch()` captures the first argument as the input and the return value as the
|
|
63
|
+
output. If the return value is an OpenAI-shaped response, it also extracts the
|
|
64
|
+
model, token counts, and tool names automatically.
|
|
65
|
+
|
|
66
|
+
## Option C — manual trace
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
client = agentwatch.AgentWatchClient(api_key="aw_live_...", agent_id="<uuid>")
|
|
70
|
+
client.trace(
|
|
71
|
+
input="Refund order #47829",
|
|
72
|
+
output="I've issued the refund…",
|
|
73
|
+
model="llama-3.3-70b-versatile",
|
|
74
|
+
latency_ms=812,
|
|
75
|
+
prompt_tokens=220, completion_tokens=180, total_tokens=400,
|
|
76
|
+
tools_used=["search_kb", "issue_refund"],
|
|
77
|
+
)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Notes
|
|
81
|
+
|
|
82
|
+
- **Fire-and-forget:** traces are POSTed on a daemon thread; delivery failures are
|
|
83
|
+
swallowed so monitoring can never break your agent.
|
|
84
|
+
- **Metadata:** attach tags via `configure(..., metadata={"env": "prod"})` or
|
|
85
|
+
per-call `@agentwatch.watch(metadata={"version": "2.1"})`.
|
|
86
|
+
- **Streaming** (`stream=True`) responses aren't fully captured yet — the trace is
|
|
87
|
+
still recorded, but token/output extraction may be partial.
|
|
88
|
+
|
|
89
|
+
MIT licensed.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""AgentWatch — predict when production AI agents degrade before they visibly fail.
|
|
2
|
+
|
|
3
|
+
Quick start:
|
|
4
|
+
|
|
5
|
+
import agentwatch
|
|
6
|
+
agentwatch.configure(api_key="aw_live_...", agent_id="<agent-uuid>")
|
|
7
|
+
|
|
8
|
+
# Option A — auto-instrument any OpenAI-compatible client (Groq / Cerebras / OpenAI):
|
|
9
|
+
from openai import OpenAI
|
|
10
|
+
client = OpenAI(base_url="https://api.groq.com/openai/v1", api_key=GROQ_KEY)
|
|
11
|
+
agentwatch.instrument_openai(client) # every chat completion now emits a trace
|
|
12
|
+
|
|
13
|
+
# Option B — wrap your agent function:
|
|
14
|
+
@agentwatch.watch()
|
|
15
|
+
def run_agent(query: str) -> str:
|
|
16
|
+
...
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from typing import Any, Callable, Dict, Optional
|
|
22
|
+
|
|
23
|
+
from .client import AgentWatchClient
|
|
24
|
+
|
|
25
|
+
__version__ = "0.1.0"
|
|
26
|
+
__all__ = [
|
|
27
|
+
"AgentWatchClient",
|
|
28
|
+
"configure",
|
|
29
|
+
"watch",
|
|
30
|
+
"instrument_openai",
|
|
31
|
+
"trace",
|
|
32
|
+
"__version__",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
_default_client: Optional[AgentWatchClient] = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def configure(api_key: str, agent_id: str, **kwargs: Any) -> AgentWatchClient:
|
|
39
|
+
"""Set the process-wide default client used by the module-level helpers."""
|
|
40
|
+
global _default_client
|
|
41
|
+
_default_client = AgentWatchClient(api_key=api_key, agent_id=agent_id, **kwargs)
|
|
42
|
+
return _default_client
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _require_default() -> AgentWatchClient:
|
|
46
|
+
if _default_client is None:
|
|
47
|
+
raise RuntimeError(
|
|
48
|
+
"AgentWatch is not configured. Call "
|
|
49
|
+
"agentwatch.configure(api_key=..., agent_id=...) first, "
|
|
50
|
+
"or pass client=AgentWatchClient(...) explicitly."
|
|
51
|
+
)
|
|
52
|
+
return _default_client
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def watch(
|
|
56
|
+
fn: Optional[Callable] = None,
|
|
57
|
+
*,
|
|
58
|
+
client: Optional[AgentWatchClient] = None,
|
|
59
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
60
|
+
) -> Callable:
|
|
61
|
+
"""Wrap a sync/async agent function. Use `@agentwatch.watch()` or `watch(fn)`."""
|
|
62
|
+
return (client or _require_default()).watch(fn, metadata=metadata)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def instrument_openai(
|
|
66
|
+
openai_client: Any, *, client: Optional[AgentWatchClient] = None
|
|
67
|
+
) -> Any:
|
|
68
|
+
"""Auto-instrument an OpenAI-compatible client (OpenAI / Groq / Cerebras / …)."""
|
|
69
|
+
return (client or _require_default()).instrument_openai(openai_client)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def trace(*, client: Optional[AgentWatchClient] = None, **kwargs: Any) -> None:
|
|
73
|
+
"""Manually submit a trace via the default (or given) client."""
|
|
74
|
+
(client or _require_default()).trace(**kwargs)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Helpers to pull trace fields out of LLM responses.
|
|
2
|
+
|
|
3
|
+
Handles both the official `openai` client's pydantic objects (attribute access)
|
|
4
|
+
and plain dicts (key access), so the same code covers OpenAI, Groq, Cerebras,
|
|
5
|
+
Together, vLLM, and anyone returning an OpenAI-compatible shape.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from typing import Any, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _get(obj: Any, key: str) -> Any:
|
|
15
|
+
"""Read `key` from a dict (by key) or an object (by attribute)."""
|
|
16
|
+
if obj is None:
|
|
17
|
+
return None
|
|
18
|
+
if isinstance(obj, dict):
|
|
19
|
+
return obj.get(key)
|
|
20
|
+
return getattr(obj, key, None)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def truncate(value: str, max_length: int) -> str:
|
|
24
|
+
if len(value) <= max_length:
|
|
25
|
+
return value
|
|
26
|
+
return value[:max_length] + "…[truncated]"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def safe_serialize(value: Any, max_length: int) -> str:
|
|
30
|
+
if value is None:
|
|
31
|
+
return ""
|
|
32
|
+
if isinstance(value, str):
|
|
33
|
+
return truncate(value, max_length)
|
|
34
|
+
try:
|
|
35
|
+
return truncate(json.dumps(value, default=str), max_length)
|
|
36
|
+
except Exception:
|
|
37
|
+
return truncate(str(value), max_length)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def extract_output(result: Any, max_length: int) -> str:
|
|
41
|
+
"""Prefer the assistant message text; fall back to a serialized dump."""
|
|
42
|
+
choices = _get(result, "choices")
|
|
43
|
+
if choices:
|
|
44
|
+
try:
|
|
45
|
+
message = _get(choices[0], "message")
|
|
46
|
+
content = _get(message, "content")
|
|
47
|
+
if isinstance(content, str):
|
|
48
|
+
return truncate(content, max_length)
|
|
49
|
+
except Exception:
|
|
50
|
+
pass
|
|
51
|
+
if isinstance(result, str):
|
|
52
|
+
return truncate(result, max_length)
|
|
53
|
+
return safe_serialize(result, max_length)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def extract_model(result: Any) -> str:
|
|
57
|
+
model = _get(result, "model")
|
|
58
|
+
return model if isinstance(model, str) and model else "unknown"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def extract_tokens(result: Any) -> Dict[str, int]:
|
|
62
|
+
usage = _get(result, "usage")
|
|
63
|
+
if usage is None:
|
|
64
|
+
return {}
|
|
65
|
+
out: Dict[str, int] = {}
|
|
66
|
+
prompt = _get(usage, "prompt_tokens")
|
|
67
|
+
completion = _get(usage, "completion_tokens")
|
|
68
|
+
total = _get(usage, "total_tokens")
|
|
69
|
+
if isinstance(prompt, int):
|
|
70
|
+
out["promptTokens"] = prompt
|
|
71
|
+
if isinstance(completion, int):
|
|
72
|
+
out["completionTokens"] = completion
|
|
73
|
+
if isinstance(total, int):
|
|
74
|
+
out["totalTokens"] = total
|
|
75
|
+
return out
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def extract_tools(result: Any) -> List[str]:
|
|
79
|
+
choices = _get(result, "choices")
|
|
80
|
+
if not choices:
|
|
81
|
+
return []
|
|
82
|
+
message = _get(choices[0], "message")
|
|
83
|
+
tool_calls = _get(message, "tool_calls")
|
|
84
|
+
if not tool_calls:
|
|
85
|
+
return []
|
|
86
|
+
names: List[str] = []
|
|
87
|
+
for call in tool_calls:
|
|
88
|
+
fn = _get(call, "function")
|
|
89
|
+
name = _get(fn, "name")
|
|
90
|
+
if isinstance(name, str):
|
|
91
|
+
names.append(name)
|
|
92
|
+
return names
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def input_from_messages(messages: Any, max_length: int) -> str:
|
|
96
|
+
"""Represent a chat request by its last user message (most descriptive)."""
|
|
97
|
+
try:
|
|
98
|
+
for msg in reversed(list(messages)):
|
|
99
|
+
if _get(msg, "role") == "user":
|
|
100
|
+
content = _get(msg, "content")
|
|
101
|
+
if isinstance(content, str):
|
|
102
|
+
return truncate(content, max_length)
|
|
103
|
+
except Exception:
|
|
104
|
+
pass
|
|
105
|
+
return safe_serialize(messages, max_length)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Fire-and-forget trace delivery.
|
|
2
|
+
|
|
3
|
+
Monitoring must never surface an error to the production caller and must never
|
|
4
|
+
add latency to its request path, so every trace is POSTed on a daemon thread and
|
|
5
|
+
all exceptions are swallowed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import threading
|
|
12
|
+
import urllib.request
|
|
13
|
+
from typing import Any, Dict
|
|
14
|
+
|
|
15
|
+
SDK_VERSION = "0.1.0"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def post_trace(endpoint: str, api_key: str, payload: Dict[str, Any], timeout: float) -> None:
|
|
19
|
+
def _send() -> None:
|
|
20
|
+
try:
|
|
21
|
+
data = json.dumps(payload).encode("utf-8")
|
|
22
|
+
request = urllib.request.Request(
|
|
23
|
+
f"{endpoint}/v1/trace",
|
|
24
|
+
data=data,
|
|
25
|
+
method="POST",
|
|
26
|
+
headers={
|
|
27
|
+
"Authorization": f"Bearer {api_key}",
|
|
28
|
+
"Content-Type": "application/json",
|
|
29
|
+
"X-SDK-Version": SDK_VERSION,
|
|
30
|
+
"User-Agent": f"agentwatch-python/{SDK_VERSION}",
|
|
31
|
+
},
|
|
32
|
+
)
|
|
33
|
+
urllib.request.urlopen(request, timeout=timeout).close()
|
|
34
|
+
except Exception:
|
|
35
|
+
# Intentional no-op: telemetry failures must not affect the caller.
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
threading.Thread(target=_send, daemon=True).start()
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""AgentWatchClient — the stateful entry point.
|
|
2
|
+
|
|
3
|
+
Mirrors the TypeScript SDK's clean-client design: it only captures a trace and
|
|
4
|
+
POSTs it to the ingestion API. All drift math (CUSUM/EWMA/judge/scoring) runs
|
|
5
|
+
server-side, so this package ships none of the engine.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import functools
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
16
|
+
|
|
17
|
+
from . import _extract
|
|
18
|
+
from ._transport import post_trace
|
|
19
|
+
|
|
20
|
+
DEFAULT_ENDPOINT = "https://api.agentwatch.dev"
|
|
21
|
+
DEFAULT_MAX_PREVIEW = 5000
|
|
22
|
+
DEFAULT_TIMEOUT = 5.0 # seconds
|
|
23
|
+
MAX_LATENCY_MS = 600_000
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _now_iso() -> str:
|
|
27
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _elapsed_ms(start: float) -> float:
|
|
31
|
+
return (time.perf_counter() - start) * 1000.0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AgentWatchClient:
|
|
35
|
+
"""Reusable client for a single agent.
|
|
36
|
+
|
|
37
|
+
Example:
|
|
38
|
+
client = AgentWatchClient(api_key="aw_live_...", agent_id="<uuid>")
|
|
39
|
+
run = client.watch(run_agent) # wrap a function
|
|
40
|
+
client.instrument_openai(openai_client) # or auto-instrument a client
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
api_key: str,
|
|
46
|
+
agent_id: str,
|
|
47
|
+
endpoint: Optional[str] = None,
|
|
48
|
+
max_preview_length: int = DEFAULT_MAX_PREVIEW,
|
|
49
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
50
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
if not api_key:
|
|
53
|
+
raise ValueError("AgentWatch: api_key is required")
|
|
54
|
+
if not agent_id:
|
|
55
|
+
raise ValueError("AgentWatch: agent_id is required")
|
|
56
|
+
self.api_key = api_key
|
|
57
|
+
self.agent_id = agent_id
|
|
58
|
+
self.endpoint = (endpoint or DEFAULT_ENDPOINT).rstrip("/")
|
|
59
|
+
self.max_preview_length = max_preview_length
|
|
60
|
+
self.timeout = timeout
|
|
61
|
+
self.metadata: Dict[str, Any] = dict(metadata or {})
|
|
62
|
+
|
|
63
|
+
# ── internals ────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
def _dispatch(
|
|
66
|
+
self,
|
|
67
|
+
*,
|
|
68
|
+
input_str: str,
|
|
69
|
+
output_str: str,
|
|
70
|
+
model: str,
|
|
71
|
+
latency_ms: float,
|
|
72
|
+
status: str,
|
|
73
|
+
tools_used: Optional[List[str]] = None,
|
|
74
|
+
tokens: Optional[Dict[str, int]] = None,
|
|
75
|
+
error: Optional[str] = None,
|
|
76
|
+
session_id: Optional[str] = None,
|
|
77
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
78
|
+
) -> None:
|
|
79
|
+
payload: Dict[str, Any] = {
|
|
80
|
+
"agentId": self.agent_id,
|
|
81
|
+
"sessionId": session_id or str(uuid.uuid4()),
|
|
82
|
+
"input": _extract.truncate(input_str, self.max_preview_length),
|
|
83
|
+
"output": _extract.truncate(output_str, self.max_preview_length),
|
|
84
|
+
"model": model or "unknown",
|
|
85
|
+
"latencyMs": max(0, min(MAX_LATENCY_MS, int(round(latency_ms)))),
|
|
86
|
+
"status": status,
|
|
87
|
+
"timestamp": _now_iso(),
|
|
88
|
+
}
|
|
89
|
+
merged_meta = {**self.metadata, **(metadata or {})}
|
|
90
|
+
if merged_meta:
|
|
91
|
+
payload["metadata"] = merged_meta
|
|
92
|
+
if tools_used:
|
|
93
|
+
payload["toolsUsed"] = tools_used
|
|
94
|
+
if tokens:
|
|
95
|
+
payload.update({k: v for k, v in tokens.items() if v is not None})
|
|
96
|
+
if error:
|
|
97
|
+
payload["error"] = _extract.truncate(error, 2000)
|
|
98
|
+
post_trace(self.endpoint, self.api_key, payload, self.timeout)
|
|
99
|
+
|
|
100
|
+
@staticmethod
|
|
101
|
+
def _input_of(args: tuple, kwargs: dict) -> Any:
|
|
102
|
+
if args:
|
|
103
|
+
return args[0]
|
|
104
|
+
if kwargs:
|
|
105
|
+
return next(iter(kwargs.values()))
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
def _dispatch_success(
|
|
109
|
+
self, result: Any, args: tuple, kwargs: dict, start: float, metadata: Optional[Dict[str, Any]]
|
|
110
|
+
) -> None:
|
|
111
|
+
tools = _extract.extract_tools(result)
|
|
112
|
+
self._dispatch(
|
|
113
|
+
input_str=_extract.safe_serialize(self._input_of(args, kwargs), self.max_preview_length),
|
|
114
|
+
output_str=_extract.extract_output(result, self.max_preview_length),
|
|
115
|
+
model=_extract.extract_model(result),
|
|
116
|
+
latency_ms=_elapsed_ms(start),
|
|
117
|
+
status="success",
|
|
118
|
+
tools_used=tools or None,
|
|
119
|
+
tokens=_extract.extract_tokens(result),
|
|
120
|
+
metadata=metadata,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# ── public API ───────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
def trace(
|
|
126
|
+
self,
|
|
127
|
+
*,
|
|
128
|
+
input: Any,
|
|
129
|
+
output: Any,
|
|
130
|
+
latency_ms: float,
|
|
131
|
+
model: str = "unknown",
|
|
132
|
+
status: str = "success",
|
|
133
|
+
tools_used: Optional[List[str]] = None,
|
|
134
|
+
prompt_tokens: Optional[int] = None,
|
|
135
|
+
completion_tokens: Optional[int] = None,
|
|
136
|
+
total_tokens: Optional[int] = None,
|
|
137
|
+
error: Optional[str] = None,
|
|
138
|
+
session_id: Optional[str] = None,
|
|
139
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
140
|
+
) -> None:
|
|
141
|
+
"""Manually submit a trace — for custom integrations you can't wrap."""
|
|
142
|
+
tokens = {
|
|
143
|
+
"promptTokens": prompt_tokens,
|
|
144
|
+
"completionTokens": completion_tokens,
|
|
145
|
+
"totalTokens": total_tokens,
|
|
146
|
+
}
|
|
147
|
+
self._dispatch(
|
|
148
|
+
input_str=_extract.safe_serialize(input, self.max_preview_length),
|
|
149
|
+
output_str=_extract.safe_serialize(output, self.max_preview_length),
|
|
150
|
+
model=model,
|
|
151
|
+
latency_ms=latency_ms,
|
|
152
|
+
status=status,
|
|
153
|
+
tools_used=tools_used,
|
|
154
|
+
tokens={k: v for k, v in tokens.items() if v is not None},
|
|
155
|
+
error=error,
|
|
156
|
+
session_id=session_id,
|
|
157
|
+
metadata=metadata,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
def watch(
|
|
161
|
+
self,
|
|
162
|
+
fn: Optional[Callable] = None,
|
|
163
|
+
*,
|
|
164
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
165
|
+
) -> Callable:
|
|
166
|
+
"""Wrap a (sync or async) agent function to auto-capture traces.
|
|
167
|
+
|
|
168
|
+
Usable as `client.watch(fn)` or as a decorator `@client.watch()`.
|
|
169
|
+
Telemetry is fire-and-forget; errors are captured then re-raised so the
|
|
170
|
+
caller's behavior is unchanged.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
def decorator(func: Callable) -> Callable:
|
|
174
|
+
if asyncio.iscoroutinefunction(func):
|
|
175
|
+
|
|
176
|
+
@functools.wraps(func)
|
|
177
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
178
|
+
start = time.perf_counter()
|
|
179
|
+
try:
|
|
180
|
+
result = await func(*args, **kwargs)
|
|
181
|
+
except Exception as err:
|
|
182
|
+
self._dispatch(
|
|
183
|
+
input_str=_extract.safe_serialize(
|
|
184
|
+
self._input_of(args, kwargs), self.max_preview_length
|
|
185
|
+
),
|
|
186
|
+
output_str="",
|
|
187
|
+
model="unknown",
|
|
188
|
+
latency_ms=_elapsed_ms(start),
|
|
189
|
+
status="error",
|
|
190
|
+
error=str(err),
|
|
191
|
+
metadata=metadata,
|
|
192
|
+
)
|
|
193
|
+
raise
|
|
194
|
+
self._dispatch_success(result, args, kwargs, start, metadata)
|
|
195
|
+
return result
|
|
196
|
+
|
|
197
|
+
return async_wrapper
|
|
198
|
+
|
|
199
|
+
@functools.wraps(func)
|
|
200
|
+
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
201
|
+
start = time.perf_counter()
|
|
202
|
+
try:
|
|
203
|
+
result = func(*args, **kwargs)
|
|
204
|
+
except Exception as err:
|
|
205
|
+
self._dispatch(
|
|
206
|
+
input_str=_extract.safe_serialize(
|
|
207
|
+
self._input_of(args, kwargs), self.max_preview_length
|
|
208
|
+
),
|
|
209
|
+
output_str="",
|
|
210
|
+
model="unknown",
|
|
211
|
+
latency_ms=_elapsed_ms(start),
|
|
212
|
+
status="error",
|
|
213
|
+
error=str(err),
|
|
214
|
+
metadata=metadata,
|
|
215
|
+
)
|
|
216
|
+
raise
|
|
217
|
+
self._dispatch_success(result, args, kwargs, start, metadata)
|
|
218
|
+
return result
|
|
219
|
+
|
|
220
|
+
return sync_wrapper
|
|
221
|
+
|
|
222
|
+
return decorator(fn) if fn is not None else decorator
|
|
223
|
+
|
|
224
|
+
def instrument_openai(self, openai_client: Any) -> Any:
|
|
225
|
+
"""Monkeypatch an OpenAI-compatible client so every chat completion emits
|
|
226
|
+
a trace automatically — one line, no per-call wrapping.
|
|
227
|
+
|
|
228
|
+
Works for OpenAI, Groq, Cerebras, Together, vLLM — anything you drive
|
|
229
|
+
through the `openai` client with a different base_url. Idempotent.
|
|
230
|
+
Returns the same client for chaining.
|
|
231
|
+
"""
|
|
232
|
+
completions = openai_client.chat.completions
|
|
233
|
+
if getattr(completions, "_agentwatch_instrumented", False):
|
|
234
|
+
return openai_client
|
|
235
|
+
|
|
236
|
+
original_create = completions.create
|
|
237
|
+
watcher = self
|
|
238
|
+
|
|
239
|
+
@functools.wraps(original_create)
|
|
240
|
+
def wrapped_create(*args: Any, **kwargs: Any) -> Any:
|
|
241
|
+
start = time.perf_counter()
|
|
242
|
+
messages = kwargs.get("messages")
|
|
243
|
+
input_str = (
|
|
244
|
+
_extract.input_from_messages(messages, watcher.max_preview_length)
|
|
245
|
+
if messages is not None
|
|
246
|
+
else ""
|
|
247
|
+
)
|
|
248
|
+
try:
|
|
249
|
+
response = original_create(*args, **kwargs)
|
|
250
|
+
except Exception as err:
|
|
251
|
+
watcher._dispatch(
|
|
252
|
+
input_str=input_str,
|
|
253
|
+
output_str="",
|
|
254
|
+
model=str(kwargs.get("model", "unknown")),
|
|
255
|
+
latency_ms=_elapsed_ms(start),
|
|
256
|
+
status="error",
|
|
257
|
+
error=str(err),
|
|
258
|
+
)
|
|
259
|
+
raise
|
|
260
|
+
watcher._dispatch(
|
|
261
|
+
input_str=input_str,
|
|
262
|
+
output_str=_extract.extract_output(response, watcher.max_preview_length),
|
|
263
|
+
model=_extract.extract_model(response) or str(kwargs.get("model", "unknown")),
|
|
264
|
+
latency_ms=_elapsed_ms(start),
|
|
265
|
+
status="success",
|
|
266
|
+
tools_used=_extract.extract_tools(response) or None,
|
|
267
|
+
tokens=_extract.extract_tokens(response),
|
|
268
|
+
)
|
|
269
|
+
return response
|
|
270
|
+
|
|
271
|
+
wrapped_create._agentwatch_original = original_create # type: ignore[attr-defined]
|
|
272
|
+
completions.create = wrapped_create # type: ignore[method-assign]
|
|
273
|
+
completions._agentwatch_instrumented = True # type: ignore[attr-defined]
|
|
274
|
+
return openai_client
|
|
275
|
+
|
|
276
|
+
def uninstrument_openai(self, openai_client: Any) -> Any:
|
|
277
|
+
"""Undo instrument_openai() (mainly for tests)."""
|
|
278
|
+
completions = openai_client.chat.completions
|
|
279
|
+
original = getattr(completions.create, "_agentwatch_original", None)
|
|
280
|
+
if original is not None:
|
|
281
|
+
completions.create = original # type: ignore[method-assign]
|
|
282
|
+
completions._agentwatch_instrumented = False # type: ignore[attr-defined]
|
|
283
|
+
return openai_client
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "agentwatch-dev"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Predict when production AI agents degrade — before they visibly fail. Python SDK."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "AgentWatch" }]
|
|
13
|
+
keywords = [
|
|
14
|
+
"llm",
|
|
15
|
+
"monitoring",
|
|
16
|
+
"drift",
|
|
17
|
+
"ai-agents",
|
|
18
|
+
"observability",
|
|
19
|
+
"openai",
|
|
20
|
+
"groq",
|
|
21
|
+
"cerebras",
|
|
22
|
+
]
|
|
23
|
+
classifiers = [
|
|
24
|
+
"Programming Language :: Python :: 3",
|
|
25
|
+
"License :: OSI Approved :: MIT License",
|
|
26
|
+
"Operating System :: OS Independent",
|
|
27
|
+
]
|
|
28
|
+
# No required runtime deps — telemetry uses the stdlib (urllib) so installing the
|
|
29
|
+
# SDK never pulls a dependency tree into a user's production agent.
|
|
30
|
+
dependencies = []
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
# Only needed if you use instrument_openai() against the official openai client
|
|
34
|
+
# (which also covers Groq / Cerebras / Together / vLLM via base_url).
|
|
35
|
+
openai = ["openai>=1.0.0"]
|
|
36
|
+
dev = ["pytest>=7.0"]
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Homepage = "https://agentwatch.dev"
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build.targets.wheel]
|
|
42
|
+
packages = ["agentwatch"]
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Unit tests for the AgentWatch Python SDK.
|
|
2
|
+
|
|
3
|
+
These never hit the network: we monkeypatch the transport to capture the exact
|
|
4
|
+
payload each code path would POST, then assert on its shape.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import uuid
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
import agentwatch
|
|
15
|
+
from agentwatch import client as client_module
|
|
16
|
+
from agentwatch.client import AgentWatchClient
|
|
17
|
+
|
|
18
|
+
AGENT_ID = str(uuid.uuid4())
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.fixture
|
|
22
|
+
def captured(monkeypatch):
|
|
23
|
+
"""Capture payloads instead of sending them."""
|
|
24
|
+
calls = []
|
|
25
|
+
|
|
26
|
+
def fake_post(endpoint, api_key, payload, timeout):
|
|
27
|
+
calls.append({"endpoint": endpoint, "api_key": api_key, "payload": payload})
|
|
28
|
+
|
|
29
|
+
monkeypatch.setattr(client_module, "post_trace", fake_post)
|
|
30
|
+
return calls
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def make_client(**kw):
|
|
34
|
+
return AgentWatchClient(api_key="aw_live_test", agent_id=AGENT_ID, **kw)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ── OpenAI-shaped fixtures (mimic both dict and attribute access) ──────────────
|
|
38
|
+
|
|
39
|
+
class _Obj:
|
|
40
|
+
def __init__(self, **kw):
|
|
41
|
+
self.__dict__.update(kw)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def openai_response(content="Hello there", model="llama-3.3-70b-versatile"):
|
|
45
|
+
return _Obj(
|
|
46
|
+
model=model,
|
|
47
|
+
usage=_Obj(prompt_tokens=42, completion_tokens=58, total_tokens=100),
|
|
48
|
+
choices=[_Obj(message=_Obj(content=content, tool_calls=[
|
|
49
|
+
_Obj(function=_Obj(name="search_kb")),
|
|
50
|
+
]))],
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ── config ─────────────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
def test_requires_api_key_and_agent_id():
|
|
57
|
+
with pytest.raises(ValueError):
|
|
58
|
+
AgentWatchClient(api_key="", agent_id=AGENT_ID)
|
|
59
|
+
with pytest.raises(ValueError):
|
|
60
|
+
AgentWatchClient(api_key="aw_live_test", agent_id="")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_module_helpers_need_configure(monkeypatch):
|
|
64
|
+
monkeypatch.setattr(agentwatch, "_default_client", None)
|
|
65
|
+
with pytest.raises(RuntimeError):
|
|
66
|
+
agentwatch.watch(lambda x: x)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ── manual trace ───────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
def test_manual_trace_builds_camelcase_payload(captured):
|
|
72
|
+
make_client().trace(
|
|
73
|
+
input="refund order 123",
|
|
74
|
+
output="done",
|
|
75
|
+
model="gpt-oss-120b",
|
|
76
|
+
latency_ms=812.7,
|
|
77
|
+
prompt_tokens=10,
|
|
78
|
+
completion_tokens=20,
|
|
79
|
+
total_tokens=30,
|
|
80
|
+
tools_used=["issue_refund"],
|
|
81
|
+
)
|
|
82
|
+
p = captured[0]["payload"]
|
|
83
|
+
assert p["agentId"] == AGENT_ID
|
|
84
|
+
assert p["input"] == "refund order 123"
|
|
85
|
+
assert p["output"] == "done"
|
|
86
|
+
assert p["model"] == "gpt-oss-120b"
|
|
87
|
+
assert p["latencyMs"] == 813 # rounded int
|
|
88
|
+
assert p["status"] == "success"
|
|
89
|
+
assert p["toolsUsed"] == ["issue_refund"]
|
|
90
|
+
assert p["promptTokens"] == 10 and p["totalTokens"] == 30
|
|
91
|
+
# sessionId must be a valid uuid the ingestion schema accepts
|
|
92
|
+
uuid.UUID(p["sessionId"])
|
|
93
|
+
assert p["timestamp"].endswith("Z")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_latency_clamped(captured):
|
|
97
|
+
make_client().trace(input="x", output="y", latency_ms=10_000_000)
|
|
98
|
+
assert captured[0]["payload"]["latencyMs"] == client_module.MAX_LATENCY_MS
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ── watch (sync) ───────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
def test_watch_sync_success_extracts_from_openai_shape(captured):
|
|
104
|
+
c = make_client()
|
|
105
|
+
|
|
106
|
+
@c.watch()
|
|
107
|
+
def run(query):
|
|
108
|
+
return openai_response(content="Refunded.")
|
|
109
|
+
|
|
110
|
+
out = run("please refund")
|
|
111
|
+
assert out.model == "llama-3.3-70b-versatile" # original return value preserved
|
|
112
|
+
p = captured[0]["payload"]
|
|
113
|
+
assert p["input"] == "please refund"
|
|
114
|
+
assert p["output"] == "Refunded." # message content, not a JSON dump
|
|
115
|
+
assert p["model"] == "llama-3.3-70b-versatile"
|
|
116
|
+
assert p["toolsUsed"] == ["search_kb"]
|
|
117
|
+
assert p["promptTokens"] == 42 and p["completionTokens"] == 58
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_watch_sync_error_captures_and_reraises(captured):
|
|
121
|
+
c = make_client()
|
|
122
|
+
|
|
123
|
+
@c.watch()
|
|
124
|
+
def run(query):
|
|
125
|
+
raise RuntimeError("boom")
|
|
126
|
+
|
|
127
|
+
with pytest.raises(RuntimeError, match="boom"):
|
|
128
|
+
run("trigger")
|
|
129
|
+
p = captured[0]["payload"]
|
|
130
|
+
assert p["status"] == "error"
|
|
131
|
+
assert p["error"] == "boom"
|
|
132
|
+
assert p["output"] == ""
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_watch_as_direct_call(captured):
|
|
136
|
+
c = make_client()
|
|
137
|
+
wrapped = c.watch(lambda q: "plain string")
|
|
138
|
+
assert wrapped("hi") == "plain string"
|
|
139
|
+
assert captured[0]["payload"]["output"] == "plain string"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ── watch (async) ──────────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
def test_watch_async(captured):
|
|
145
|
+
c = make_client()
|
|
146
|
+
|
|
147
|
+
@c.watch()
|
|
148
|
+
async def run(query):
|
|
149
|
+
return openai_response(content="async ok")
|
|
150
|
+
|
|
151
|
+
result = asyncio.run(run("q"))
|
|
152
|
+
assert result.usage.total_tokens == 100
|
|
153
|
+
assert captured[0]["payload"]["output"] == "async ok"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ── instrument_openai ──────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
def test_instrument_openai_traces_and_is_idempotent(captured):
|
|
159
|
+
# Fake openai client: client.chat.completions.create(...)
|
|
160
|
+
completions = _Obj()
|
|
161
|
+
completions.create = lambda **kw: openai_response(content="instrumented")
|
|
162
|
+
chat = _Obj(completions=completions)
|
|
163
|
+
fake_client = _Obj(chat=chat)
|
|
164
|
+
|
|
165
|
+
c = make_client()
|
|
166
|
+
c.instrument_openai(fake_client)
|
|
167
|
+
c.instrument_openai(fake_client) # second call must be a no-op
|
|
168
|
+
|
|
169
|
+
resp = fake_client.chat.completions.create(
|
|
170
|
+
model="llama-3.3-70b-versatile",
|
|
171
|
+
messages=[{"role": "user", "content": "hello world"}],
|
|
172
|
+
)
|
|
173
|
+
assert resp.choices[0].message.content == "instrumented"
|
|
174
|
+
assert len(captured) == 1 # exactly one trace, not two (idempotent)
|
|
175
|
+
p = captured[0]["payload"]
|
|
176
|
+
assert p["input"] == "hello world" # last user message
|
|
177
|
+
assert p["output"] == "instrumented"
|
|
178
|
+
assert p["model"] == "llama-3.3-70b-versatile"
|
|
179
|
+
|
|
180
|
+
# uninstrument restores the original
|
|
181
|
+
c.uninstrument_openai(fake_client)
|
|
182
|
+
fake_client.chat.completions.create(
|
|
183
|
+
model="m", messages=[{"role": "user", "content": "again"}]
|
|
184
|
+
)
|
|
185
|
+
assert len(captured) == 1 # no new trace after uninstrument
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def test_metadata_merged(captured):
|
|
189
|
+
c = make_client(metadata={"env": "prod"})
|
|
190
|
+
|
|
191
|
+
@c.watch(metadata={"version": "2.1"})
|
|
192
|
+
def run(q):
|
|
193
|
+
return "ok"
|
|
194
|
+
|
|
195
|
+
run("x")
|
|
196
|
+
assert captured[0]["payload"]["metadata"] == {"env": "prod", "version": "2.1"}
|