agentwatch-dev 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentwatch/__init__.py +74 -0
- agentwatch/_extract.py +105 -0
- agentwatch/_transport.py +38 -0
- agentwatch/client.py +283 -0
- agentwatch_dev-0.1.0.dist-info/METADATA +108 -0
- agentwatch_dev-0.1.0.dist-info/RECORD +8 -0
- agentwatch_dev-0.1.0.dist-info/WHEEL +4 -0
- agentwatch_dev-0.1.0.dist-info/licenses/LICENSE +21 -0
agentwatch/__init__.py
ADDED
|
@@ -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)
|
agentwatch/_extract.py
ADDED
|
@@ -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)
|
agentwatch/_transport.py
ADDED
|
@@ -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()
|
agentwatch/client.py
ADDED
|
@@ -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,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,8 @@
|
|
|
1
|
+
agentwatch/__init__.py,sha256=eaSLdz6HQGADR1r1NqVMr4yWroHvN2SO1xFO7z7kaDs,2423
|
|
2
|
+
agentwatch/_extract.py,sha256=f7y-CEqsdUsqwOFwzOm-pxEagM9n1oRwnNOxTMyRFrw,3350
|
|
3
|
+
agentwatch/_transport.py,sha256=37LWah05wSAoNHmGEQiDDcx5TH4eUda-dJdhcMa4lkA,1283
|
|
4
|
+
agentwatch/client.py,sha256=rVPMVoaLLVT_KFM2G9rcqjeB3G5yP3fNgQ0VjWaQTDk,11234
|
|
5
|
+
agentwatch_dev-0.1.0.dist-info/METADATA,sha256=p4qFOugmaNUgUoZg4oHRpu9EwXAsH0O_gFJIfPwWGtg,3429
|
|
6
|
+
agentwatch_dev-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
7
|
+
agentwatch_dev-0.1.0.dist-info/licenses/LICENSE,sha256=sNFI0vIuwEXtlgwJktNQrqEh55kX9p63RuwnHX8Z0ps,1066
|
|
8
|
+
agentwatch_dev-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|