sythra 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.
- sythra-0.1.0/PKG-INFO +49 -0
- sythra-0.1.0/README.md +35 -0
- sythra-0.1.0/pyproject.toml +24 -0
- sythra-0.1.0/setup.cfg +4 -0
- sythra-0.1.0/sythra/__init__.py +5 -0
- sythra-0.1.0/sythra/client.py +154 -0
- sythra-0.1.0/sythra/crewai_listener.py +136 -0
- sythra-0.1.0/sythra/errors.py +37 -0
- sythra-0.1.0/sythra/langchain_handler.py +117 -0
- sythra-0.1.0/sythra/openai_agents_listener.py +63 -0
- sythra-0.1.0/sythra/pricing.py +49 -0
- sythra-0.1.0/sythra.egg-info/PKG-INFO +49 -0
- sythra-0.1.0/sythra.egg-info/SOURCES.txt +14 -0
- sythra-0.1.0/sythra.egg-info/dependency_links.txt +1 -0
- sythra-0.1.0/sythra.egg-info/requires.txt +1 -0
- sythra-0.1.0/sythra.egg-info/top_level.txt +1 -0
sythra-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sythra
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Runtime intelligence for AI agents: find the first point where a run went wrong, what it cost, and how to fix it.
|
|
5
|
+
Author-email: Sythra <sythra.hq@gmail.com>
|
|
6
|
+
Project-URL: Homepage, https://sythra.vercel.app
|
|
7
|
+
Project-URL: Repository, https://github.com/sythrahq/sythra
|
|
8
|
+
Keywords: ai,agents,observability,llm,debugging
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
Requires-Dist: httpx>=0.24
|
|
14
|
+
|
|
15
|
+
# Sythra
|
|
16
|
+
|
|
17
|
+
Runtime intelligence for AI agents. When a run goes wrong, Sythra finds the first point where it diverged, shows what it cascaded into, what it cost, and what to do about it.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
pip install sythra
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Quick start
|
|
26
|
+
|
|
27
|
+
Create an agent in your dashboard at https://sythra.vercel.app to get an API key, then wrap your agent function:
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from sythra import Sythra
|
|
31
|
+
|
|
32
|
+
sythra = Sythra(api_key="YOUR_API_KEY", agent_name="my-agent")
|
|
33
|
+
|
|
34
|
+
@sythra.observe
|
|
35
|
+
def run_agent(question):
|
|
36
|
+
... # your agent code
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Each call is recorded with its outcome, duration and steps. Works with LangChain, LangGraph, CrewAI and the OpenAI Agents SDK.
|
|
40
|
+
|
|
41
|
+
## Privacy
|
|
42
|
+
|
|
43
|
+
Sythra records what your agent did (tools, models, cost, outcome), not what it said. Prompt and response content is never captured unless you turn it on.
|
|
44
|
+
|
|
45
|
+
## Links
|
|
46
|
+
|
|
47
|
+
- Dashboard: https://sythra.vercel.app
|
|
48
|
+
- Source: https://github.com/sythrahq/sythra
|
|
49
|
+
- Support: sythra.hq@gmail.com
|
sythra-0.1.0/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Sythra
|
|
2
|
+
|
|
3
|
+
Runtime intelligence for AI agents. When a run goes wrong, Sythra finds the first point where it diverged, shows what it cascaded into, what it cost, and what to do about it.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
pip install sythra
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
Create an agent in your dashboard at https://sythra.vercel.app to get an API key, then wrap your agent function:
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from sythra import Sythra
|
|
17
|
+
|
|
18
|
+
sythra = Sythra(api_key="YOUR_API_KEY", agent_name="my-agent")
|
|
19
|
+
|
|
20
|
+
@sythra.observe
|
|
21
|
+
def run_agent(question):
|
|
22
|
+
... # your agent code
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Each call is recorded with its outcome, duration and steps. Works with LangChain, LangGraph, CrewAI and the OpenAI Agents SDK.
|
|
26
|
+
|
|
27
|
+
## Privacy
|
|
28
|
+
|
|
29
|
+
Sythra records what your agent did (tools, models, cost, outcome), not what it said. Prompt and response content is never captured unless you turn it on.
|
|
30
|
+
|
|
31
|
+
## Links
|
|
32
|
+
|
|
33
|
+
- Dashboard: https://sythra.vercel.app
|
|
34
|
+
- Source: https://github.com/sythrahq/sythra
|
|
35
|
+
- Support: sythra.hq@gmail.com
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "sythra"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Runtime intelligence for AI agents: find the first point where a run went wrong, what it cost, and how to fix it."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
dependencies = ["httpx>=0.24"]
|
|
12
|
+
authors = [{ name = "Sythra", email = "sythra.hq@gmail.com" }]
|
|
13
|
+
keywords = ["ai", "agents", "observability", "llm", "debugging"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://sythra.vercel.app"
|
|
21
|
+
Repository = "https://github.com/sythrahq/sythra"
|
|
22
|
+
|
|
23
|
+
[tool.setuptools]
|
|
24
|
+
packages = ["sythra"]
|
sythra-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import time
|
|
3
|
+
import functools
|
|
4
|
+
import asyncio
|
|
5
|
+
import threading
|
|
6
|
+
import atexit
|
|
7
|
+
import httpx
|
|
8
|
+
from typing import Callable
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Sythra:
|
|
12
|
+
"""
|
|
13
|
+
The main SDK client. A developer creates one instance of this,
|
|
14
|
+
then uses @sythra.observe to wrap their agent function.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
api_key: str,
|
|
20
|
+
agent_name: str,
|
|
21
|
+
capture_content: bool = False,
|
|
22
|
+
flush_interval: float = 0.5,
|
|
23
|
+
framework: str | None = None,
|
|
24
|
+
base_url: str | None = None,
|
|
25
|
+
):
|
|
26
|
+
self.api_key = api_key
|
|
27
|
+
self.agent_name = agent_name
|
|
28
|
+
self.capture_content = capture_content
|
|
29
|
+
self.framework = framework
|
|
30
|
+
|
|
31
|
+
# Where to send data. Order of priority: the base_url argument, then
|
|
32
|
+
# the SYTHRA_BASE_URL environment variable, then the live site.
|
|
33
|
+
# For local testing, set SYTHRA_BASE_URL=http://localhost:3000
|
|
34
|
+
resolved_base = base_url or os.environ.get("SYTHRA_BASE_URL") or "https://sythra.vercel.app"
|
|
35
|
+
self._ingest_url = resolved_base.rstrip("/") + "/api/ingest"
|
|
36
|
+
|
|
37
|
+
self._current_steps = []
|
|
38
|
+
|
|
39
|
+
self._flush_interval = flush_interval
|
|
40
|
+
self._event_queue = []
|
|
41
|
+
self._queue_lock = threading.Lock()
|
|
42
|
+
self._stop_flag = threading.Event()
|
|
43
|
+
|
|
44
|
+
self._flush_thread = threading.Thread(target=self._flush_loop, daemon=True)
|
|
45
|
+
self._flush_thread.start()
|
|
46
|
+
|
|
47
|
+
atexit.register(self.flush)
|
|
48
|
+
|
|
49
|
+
def log_step(
|
|
50
|
+
self,
|
|
51
|
+
step_type: str,
|
|
52
|
+
step_name: str,
|
|
53
|
+
status: str = "success",
|
|
54
|
+
latency_ms: int = 0,
|
|
55
|
+
cost_usd: float = 0.0,
|
|
56
|
+
error_type: str | None = None,
|
|
57
|
+
error_message: str | None = None,
|
|
58
|
+
prompt_tokens: int | None = None,
|
|
59
|
+
completion_tokens: int | None = None,
|
|
60
|
+
model_name: str | None = None,
|
|
61
|
+
):
|
|
62
|
+
self._current_steps.append({
|
|
63
|
+
"step_type": step_type,
|
|
64
|
+
"step_name": step_name,
|
|
65
|
+
"status": status,
|
|
66
|
+
"latency_ms": latency_ms,
|
|
67
|
+
"cost_usd": cost_usd,
|
|
68
|
+
"error_type": error_type,
|
|
69
|
+
"error_message": error_message,
|
|
70
|
+
"prompt_tokens": prompt_tokens,
|
|
71
|
+
"completion_tokens": completion_tokens,
|
|
72
|
+
"model_name": model_name,
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
def observe(self, func: Callable) -> Callable:
|
|
76
|
+
if asyncio.iscoroutinefunction(func):
|
|
77
|
+
@functools.wraps(func)
|
|
78
|
+
async def async_wrapper(*args, **kwargs):
|
|
79
|
+
start_time = time.time()
|
|
80
|
+
outcome = "success"
|
|
81
|
+
error_type = None
|
|
82
|
+
error_message = None
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
result = await func(*args, **kwargs)
|
|
86
|
+
return result
|
|
87
|
+
except Exception as e:
|
|
88
|
+
outcome = "failure"
|
|
89
|
+
error_type = type(e).__name__
|
|
90
|
+
error_message = str(e)[:300]
|
|
91
|
+
raise
|
|
92
|
+
finally:
|
|
93
|
+
duration_ms = int((time.time() - start_time) * 1000)
|
|
94
|
+
self._queue_event(outcome, duration_ms, error_type, error_message=error_message)
|
|
95
|
+
|
|
96
|
+
return async_wrapper
|
|
97
|
+
else:
|
|
98
|
+
@functools.wraps(func)
|
|
99
|
+
def sync_wrapper(*args, **kwargs):
|
|
100
|
+
start_time = time.time()
|
|
101
|
+
outcome = "success"
|
|
102
|
+
error_type = None
|
|
103
|
+
error_message = None
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
result = func(*args, **kwargs)
|
|
107
|
+
return result
|
|
108
|
+
except Exception as e:
|
|
109
|
+
outcome = "failure"
|
|
110
|
+
error_type = type(e).__name__
|
|
111
|
+
error_message = str(e)[:300]
|
|
112
|
+
raise
|
|
113
|
+
finally:
|
|
114
|
+
duration_ms = int((time.time() - start_time) * 1000)
|
|
115
|
+
self._queue_event(outcome, duration_ms, error_type, error_message=error_message)
|
|
116
|
+
|
|
117
|
+
return sync_wrapper
|
|
118
|
+
|
|
119
|
+
def _queue_event(self, outcome: str, duration_ms: int, error_type: str | None, framework: str | None = None, error_message: str | None = None):
|
|
120
|
+
event = {
|
|
121
|
+
"agent_name": self.agent_name,
|
|
122
|
+
"outcome": outcome,
|
|
123
|
+
"duration_ms": duration_ms,
|
|
124
|
+
"error_type": error_type,
|
|
125
|
+
"error_message": error_message,
|
|
126
|
+
"steps": self._current_steps,
|
|
127
|
+
"framework": framework or self.framework,
|
|
128
|
+
}
|
|
129
|
+
with self._queue_lock:
|
|
130
|
+
self._event_queue.append(event)
|
|
131
|
+
self._current_steps = []
|
|
132
|
+
|
|
133
|
+
def _flush_loop(self):
|
|
134
|
+
while not self._stop_flag.wait(self._flush_interval):
|
|
135
|
+
self.flush()
|
|
136
|
+
|
|
137
|
+
def flush(self):
|
|
138
|
+
with self._queue_lock:
|
|
139
|
+
if not self._event_queue:
|
|
140
|
+
return
|
|
141
|
+
events_to_send = self._event_queue
|
|
142
|
+
self._event_queue = []
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
response = httpx.post(
|
|
146
|
+
self._ingest_url,
|
|
147
|
+
json={"events": events_to_send},
|
|
148
|
+
headers={"Authorization": f"Bearer {self.api_key}"},
|
|
149
|
+
timeout=5.0,
|
|
150
|
+
)
|
|
151
|
+
if response.status_code != 200:
|
|
152
|
+
print(f"[sythra] warning: ingest returned {response.status_code}")
|
|
153
|
+
except Exception as e:
|
|
154
|
+
print(f"[sythra] warning: could not reach Sythra ({type(e).__name__}) — events not sent")
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from sythra.errors import extract_error_message
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
from crewai.events import (
|
|
8
|
+
BaseEventListener,
|
|
9
|
+
CrewKickoffStartedEvent,
|
|
10
|
+
CrewKickoffCompletedEvent,
|
|
11
|
+
CrewKickoffFailedEvent,
|
|
12
|
+
LLMCallStartedEvent,
|
|
13
|
+
LLMCallCompletedEvent,
|
|
14
|
+
LLMCallFailedEvent,
|
|
15
|
+
ToolUsageStartedEvent,
|
|
16
|
+
ToolUsageFinishedEvent,
|
|
17
|
+
ToolUsageErrorEvent,
|
|
18
|
+
)
|
|
19
|
+
except ImportError:
|
|
20
|
+
raise ImportError(
|
|
21
|
+
"CrewAI auto-instrumentation requires crewai. "
|
|
22
|
+
"Install it with: pip install crewai"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SythraCrewListener(BaseEventListener):
|
|
27
|
+
"""
|
|
28
|
+
Register this once and Sythra captures every LLM call, tool call, and
|
|
29
|
+
crew run automatically — no manual @sythra.observe or log_step()
|
|
30
|
+
calls needed.
|
|
31
|
+
|
|
32
|
+
Usage:
|
|
33
|
+
from sythra.crewai_listener import SythraCrewListener
|
|
34
|
+
SythraCrewListener(sythra) # keep a reference so it isn't garbage collected
|
|
35
|
+
crew.kickoff()
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, sythra_client):
|
|
39
|
+
super().__init__()
|
|
40
|
+
self.sythra = sythra_client
|
|
41
|
+
self._crew_start_time: Optional[float] = None
|
|
42
|
+
# CrewAI's events don't carry a per-call ID like LangChain's
|
|
43
|
+
# run_id, so we track "what's currently running" as a stack —
|
|
44
|
+
# correct for CrewAI's normal sequential execution.
|
|
45
|
+
self._llm_call_stack = []
|
|
46
|
+
self._tool_call_stack = []
|
|
47
|
+
|
|
48
|
+
def setup_listeners(self, crewai_event_bus):
|
|
49
|
+
@crewai_event_bus.on(CrewKickoffStartedEvent)
|
|
50
|
+
def on_crew_start(source, event):
|
|
51
|
+
self._crew_start_time = time.time()
|
|
52
|
+
|
|
53
|
+
@crewai_event_bus.on(CrewKickoffCompletedEvent)
|
|
54
|
+
def on_crew_complete(source, event):
|
|
55
|
+
if self._crew_start_time is not None:
|
|
56
|
+
duration_ms = int((time.time() - self._crew_start_time) * 1000)
|
|
57
|
+
self.sythra._queue_event("success", duration_ms, None, framework="crewai")
|
|
58
|
+
self._crew_start_time = None
|
|
59
|
+
|
|
60
|
+
@crewai_event_bus.on(CrewKickoffFailedEvent)
|
|
61
|
+
def on_crew_failed(source, event):
|
|
62
|
+
if self._crew_start_time is not None:
|
|
63
|
+
duration_ms = int((time.time() - self._crew_start_time) * 1000)
|
|
64
|
+
error = getattr(event, "error", None)
|
|
65
|
+
error_type = type(error).__name__ if isinstance(error, BaseException) else "CrewFailed"
|
|
66
|
+
error_message = extract_error_message(error) if error else None
|
|
67
|
+
self.sythra._queue_event("failure", duration_ms, error_type, framework="crewai", error_message=error_message)
|
|
68
|
+
self._crew_start_time = None
|
|
69
|
+
|
|
70
|
+
@crewai_event_bus.on(LLMCallStartedEvent)
|
|
71
|
+
def on_llm_start(source, event):
|
|
72
|
+
self._llm_call_stack.append(time.time())
|
|
73
|
+
|
|
74
|
+
@crewai_event_bus.on(LLMCallCompletedEvent)
|
|
75
|
+
def on_llm_complete(source, event):
|
|
76
|
+
start = self._llm_call_stack.pop() if self._llm_call_stack else None
|
|
77
|
+
latency_ms = int((time.time() - start) * 1000) if start else 0
|
|
78
|
+
|
|
79
|
+
prompt_tokens = None
|
|
80
|
+
completion_tokens = None
|
|
81
|
+
model_name = getattr(event, "model", None)
|
|
82
|
+
usage = getattr(event, "usage_metrics", None) or getattr(event, "usage", None)
|
|
83
|
+
if usage:
|
|
84
|
+
prompt_tokens = getattr(usage, "prompt_tokens", None)
|
|
85
|
+
completion_tokens = getattr(usage, "completion_tokens", None)
|
|
86
|
+
|
|
87
|
+
self.sythra.log_step(
|
|
88
|
+
"llm_call",
|
|
89
|
+
"LLM call",
|
|
90
|
+
latency_ms=latency_ms,
|
|
91
|
+
prompt_tokens=prompt_tokens,
|
|
92
|
+
completion_tokens=completion_tokens,
|
|
93
|
+
model_name=model_name,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# If there's no crew run currently tracked, this LLM call is
|
|
97
|
+
# standalone (no Crew wrapping it) — same edge case we fixed
|
|
98
|
+
# for LangChain. Treat it as the whole execution.
|
|
99
|
+
if self._crew_start_time is None:
|
|
100
|
+
self.sythra._queue_event("success", latency_ms, None, framework="crewai")
|
|
101
|
+
|
|
102
|
+
@crewai_event_bus.on(LLMCallFailedEvent)
|
|
103
|
+
def on_llm_failed(source, event):
|
|
104
|
+
start = self._llm_call_stack.pop() if self._llm_call_stack else None
|
|
105
|
+
latency_ms = int((time.time() - start) * 1000) if start else 0
|
|
106
|
+
error = getattr(event, "error", "LLMCallFailed")
|
|
107
|
+
error_type = type(error).__name__ if isinstance(error, BaseException) else "LLMCallFailed"
|
|
108
|
+
error_message = extract_error_message(error)
|
|
109
|
+
self.sythra.log_step(
|
|
110
|
+
"llm_call", "LLM call", status="error", latency_ms=latency_ms,
|
|
111
|
+
error_type=error_type, error_message=error_message,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
@crewai_event_bus.on(ToolUsageStartedEvent)
|
|
115
|
+
def on_tool_start(source, event):
|
|
116
|
+
self._tool_call_stack.append(time.time())
|
|
117
|
+
|
|
118
|
+
@crewai_event_bus.on(ToolUsageFinishedEvent)
|
|
119
|
+
def on_tool_finish(source, event):
|
|
120
|
+
start = self._tool_call_stack.pop() if self._tool_call_stack else None
|
|
121
|
+
latency_ms = int((time.time() - start) * 1000) if start else 0
|
|
122
|
+
tool_name = getattr(event, "tool_name", "tool")
|
|
123
|
+
self.sythra.log_step("tool_call", tool_name, latency_ms=latency_ms)
|
|
124
|
+
|
|
125
|
+
@crewai_event_bus.on(ToolUsageErrorEvent)
|
|
126
|
+
def on_tool_error(source, event):
|
|
127
|
+
start = self._tool_call_stack.pop() if self._tool_call_stack else None
|
|
128
|
+
latency_ms = int((time.time() - start) * 1000) if start else 0
|
|
129
|
+
tool_name = getattr(event, "tool_name", "tool")
|
|
130
|
+
error = getattr(event, "error", "ToolUsageError")
|
|
131
|
+
error_type = type(error).__name__ if isinstance(error, BaseException) else "ToolUsageError"
|
|
132
|
+
error_message = extract_error_message(error)
|
|
133
|
+
self.sythra.log_step(
|
|
134
|
+
"tool_call", tool_name, status="error", latency_ms=latency_ms,
|
|
135
|
+
error_type=error_type, error_message=error_message,
|
|
136
|
+
)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def extract_error_message(error: BaseException) -> str:
|
|
6
|
+
"""
|
|
7
|
+
Turn a raw exception into a short, human-readable message.
|
|
8
|
+
|
|
9
|
+
Many LLM provider errors (Groq, OpenAI, etc.) raise an exception whose
|
|
10
|
+
str() is a long message wrapping embedded JSON, e.g.:
|
|
11
|
+
GroqException - {"error":{"message":"The model 'x' does not
|
|
12
|
+
exist...","type":"invalid_request_error"}}
|
|
13
|
+
|
|
14
|
+
That's unreadable on a dashboard. This pulls the real message out of
|
|
15
|
+
the embedded JSON when there is one, and falls back to a trimmed
|
|
16
|
+
version of the raw string when there isn't.
|
|
17
|
+
"""
|
|
18
|
+
raw = str(error)
|
|
19
|
+
|
|
20
|
+
# Look for the first {...} block in the string and try to parse it.
|
|
21
|
+
match = re.search(r"\{.*\}", raw, re.DOTALL)
|
|
22
|
+
if match:
|
|
23
|
+
try:
|
|
24
|
+
parsed = json.loads(match.group(0))
|
|
25
|
+
# Common shapes: {"error": {"message": "..."}} or {"message": "..."}
|
|
26
|
+
if isinstance(parsed, dict):
|
|
27
|
+
err_obj = parsed.get("error", parsed)
|
|
28
|
+
if isinstance(err_obj, dict) and "message" in err_obj:
|
|
29
|
+
return str(err_obj["message"])
|
|
30
|
+
except (json.JSONDecodeError, AttributeError, TypeError):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
# No embedded JSON, or it didn't parse — fall back to the raw string,
|
|
34
|
+
# trimmed so it never becomes its own wall of text.
|
|
35
|
+
if len(raw) > 300:
|
|
36
|
+
return raw[:300] + "..."
|
|
37
|
+
return raw
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from typing import Any, Dict, List, Optional
|
|
3
|
+
from uuid import UUID
|
|
4
|
+
|
|
5
|
+
from sythra.errors import extract_error_message
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
from langchain_core.callbacks.base import BaseCallbackHandler
|
|
9
|
+
except ImportError:
|
|
10
|
+
raise ImportError(
|
|
11
|
+
"LangChain auto-instrumentation requires langchain-core. "
|
|
12
|
+
"Install it with: pip install langchain-core"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SythraCallbackHandler(BaseCallbackHandler):
|
|
17
|
+
"""
|
|
18
|
+
Drop this into any LangChain chain/agent's callbacks list and Sythra
|
|
19
|
+
captures every LLM call and tool call automatically — no manual
|
|
20
|
+
@sythra.observe or log_step() calls needed.
|
|
21
|
+
|
|
22
|
+
Usage:
|
|
23
|
+
handler = SythraCallbackHandler(sythra)
|
|
24
|
+
result = my_chain.invoke(input, config={"callbacks": [handler]})
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, sythra_client):
|
|
28
|
+
self.sythra = sythra_client
|
|
29
|
+
self._start_times: Dict[UUID, float] = {}
|
|
30
|
+
self._chain_start_time: Optional[float] = None
|
|
31
|
+
|
|
32
|
+
def on_chain_start(
|
|
33
|
+
self, serialized: Dict[str, Any], inputs: Dict[str, Any], *, run_id: UUID, parent_run_id: Optional[UUID] = None, **kwargs: Any
|
|
34
|
+
) -> None:
|
|
35
|
+
if parent_run_id is None:
|
|
36
|
+
self._chain_start_time = time.time()
|
|
37
|
+
|
|
38
|
+
def on_chain_end(
|
|
39
|
+
self, outputs: Dict[str, Any], *, run_id: UUID, parent_run_id: Optional[UUID] = None, **kwargs: Any
|
|
40
|
+
) -> None:
|
|
41
|
+
if parent_run_id is None and self._chain_start_time is not None:
|
|
42
|
+
duration_ms = int((time.time() - self._chain_start_time) * 1000)
|
|
43
|
+
self.sythra._queue_event("success", duration_ms, None)
|
|
44
|
+
self._chain_start_time = None
|
|
45
|
+
|
|
46
|
+
def on_chain_error(
|
|
47
|
+
self, error: BaseException, *, run_id: UUID, parent_run_id: Optional[UUID] = None, **kwargs: Any
|
|
48
|
+
) -> None:
|
|
49
|
+
if parent_run_id is None and self._chain_start_time is not None:
|
|
50
|
+
duration_ms = int((time.time() - self._chain_start_time) * 1000)
|
|
51
|
+
self.sythra._queue_event(
|
|
52
|
+
"failure", duration_ms, type(error).__name__,
|
|
53
|
+
error_message=extract_error_message(error),
|
|
54
|
+
)
|
|
55
|
+
self._chain_start_time = None
|
|
56
|
+
|
|
57
|
+
def on_llm_start(
|
|
58
|
+
self, serialized: Dict[str, Any], prompts: List[str], *, run_id: UUID, **kwargs: Any
|
|
59
|
+
) -> None:
|
|
60
|
+
self._start_times[run_id] = time.time()
|
|
61
|
+
|
|
62
|
+
def on_llm_end(self, response: Any, *, run_id: UUID, parent_run_id: Optional[UUID] = None, **kwargs: Any) -> None:
|
|
63
|
+
start = self._start_times.pop(run_id, None)
|
|
64
|
+
latency_ms = int((time.time() - start) * 1000) if start else 0
|
|
65
|
+
|
|
66
|
+
prompt_tokens = None
|
|
67
|
+
completion_tokens = None
|
|
68
|
+
model_name = None
|
|
69
|
+
try:
|
|
70
|
+
usage = response.llm_output.get("token_usage", {}) if response.llm_output else {}
|
|
71
|
+
model_name = response.llm_output.get("model_name") if response.llm_output else None
|
|
72
|
+
prompt_tokens = usage.get("prompt_tokens")
|
|
73
|
+
completion_tokens = usage.get("completion_tokens")
|
|
74
|
+
except Exception:
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
self.sythra.log_step(
|
|
78
|
+
"llm_call",
|
|
79
|
+
"LLM call",
|
|
80
|
+
latency_ms=latency_ms,
|
|
81
|
+
prompt_tokens=prompt_tokens,
|
|
82
|
+
completion_tokens=completion_tokens,
|
|
83
|
+
model_name=model_name,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
if parent_run_id is None:
|
|
87
|
+
self.sythra._queue_event("success", latency_ms, None)
|
|
88
|
+
|
|
89
|
+
def on_llm_error(self, error: BaseException, *, run_id: UUID, **kwargs: Any) -> None:
|
|
90
|
+
start = self._start_times.pop(run_id, None)
|
|
91
|
+
latency_ms = int((time.time() - start) * 1000) if start else 0
|
|
92
|
+
self.sythra.log_step(
|
|
93
|
+
"llm_call", "LLM call", status="error", latency_ms=latency_ms,
|
|
94
|
+
error_type=type(error).__name__,
|
|
95
|
+
error_message=extract_error_message(error),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def on_tool_start(
|
|
99
|
+
self, serialized: Dict[str, Any], input_str: str, *, run_id: UUID, **kwargs: Any
|
|
100
|
+
) -> None:
|
|
101
|
+
self._start_times[run_id] = time.time()
|
|
102
|
+
|
|
103
|
+
def on_tool_end(self, output: Any, *, run_id: UUID, **kwargs: Any) -> None:
|
|
104
|
+
start = self._start_times.pop(run_id, None)
|
|
105
|
+
latency_ms = int((time.time() - start) * 1000) if start else 0
|
|
106
|
+
tool_name = kwargs.get("name", "tool")
|
|
107
|
+
self.sythra.log_step("tool_call", tool_name, latency_ms=latency_ms)
|
|
108
|
+
|
|
109
|
+
def on_tool_error(self, error: BaseException, *, run_id: UUID, **kwargs: Any) -> None:
|
|
110
|
+
start = self._start_times.pop(run_id, None)
|
|
111
|
+
latency_ms = int((time.time() - start) * 1000) if start else 0
|
|
112
|
+
tool_name = kwargs.get("name", "tool")
|
|
113
|
+
self.sythra.log_step(
|
|
114
|
+
"tool_call", tool_name, status="error", latency_ms=latency_ms,
|
|
115
|
+
error_type=type(error).__name__,
|
|
116
|
+
error_message=extract_error_message(error),
|
|
117
|
+
)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import time
|
|
3
|
+
import asyncio
|
|
4
|
+
from dotenv import load_dotenv
|
|
5
|
+
from openai import AsyncOpenAI
|
|
6
|
+
from agents import Agent, Runner
|
|
7
|
+
from agents.models.openai_chatcompletions import OpenAIChatCompletionsModel
|
|
8
|
+
|
|
9
|
+
from sythra.client import Sythra
|
|
10
|
+
from sythra.openai_agents_listener import SythraAgentsHooks
|
|
11
|
+
from sythra.errors import extract_error_message
|
|
12
|
+
|
|
13
|
+
load_dotenv()
|
|
14
|
+
os.environ.setdefault("OPENAI_AGENTS_DISABLE_TRACING", "1")
|
|
15
|
+
|
|
16
|
+
sythra = Sythra(
|
|
17
|
+
api_key="sk-sythra-6fb7ec0d9ecf410ece3d88ad6d80a1b53f900f26f85a2c8d",
|
|
18
|
+
agent_name="main agent",
|
|
19
|
+
framework="openai-agents",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
hooks = SythraAgentsHooks(sythra)
|
|
23
|
+
|
|
24
|
+
groq_client = AsyncOpenAI(
|
|
25
|
+
api_key=os.getenv("GROQ_API_KEY"),
|
|
26
|
+
base_url="https://api.groq.com/openai/v1",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
groq_model = OpenAIChatCompletionsModel(
|
|
30
|
+
model="openai/gpt-oss-20b",
|
|
31
|
+
openai_client=groq_client,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
agent = Agent(
|
|
35
|
+
name="Assistant",
|
|
36
|
+
instructions="You are a helpful, concise assistant.",
|
|
37
|
+
model=groq_model,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def main():
|
|
42
|
+
start_time = time.time()
|
|
43
|
+
try:
|
|
44
|
+
result = await Runner.run(agent, "In one short sentence, what is the capital of Italy?", hooks=hooks)
|
|
45
|
+
duration_ms = int((time.time() - start_time) * 1000)
|
|
46
|
+
sythra._queue_event("success", duration_ms, None, framework="openai-agents")
|
|
47
|
+
print("\nResult:", result.final_output)
|
|
48
|
+
except Exception as e:
|
|
49
|
+
duration_ms = int((time.time() - start_time) * 1000)
|
|
50
|
+
sythra._queue_event(
|
|
51
|
+
"failure", duration_ms, type(e).__name__,
|
|
52
|
+
framework="openai-agents",
|
|
53
|
+
error_message=extract_error_message(e),
|
|
54
|
+
)
|
|
55
|
+
raise
|
|
56
|
+
finally:
|
|
57
|
+
sythra.flush()
|
|
58
|
+
time.sleep(1)
|
|
59
|
+
print("Done — check the Executions page for a new 'main agent' entry.")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
if __name__ == "__main__":
|
|
63
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Per-model token pricing, in dollars per token (not per million — converted
|
|
3
|
+
here so cost calculations elsewhere stay simple multiplication).
|
|
4
|
+
|
|
5
|
+
IMPORTANT — this table WILL go stale. LLM pricing changes often (OpenAI
|
|
6
|
+
raised its top-tier pricing 350% in a single move in April 2026; Anthropic
|
|
7
|
+
cut Opus pricing 67% at the 4.6 launch). This is a best-effort snapshot,
|
|
8
|
+
not a live feed. Review and update these numbers periodically — a wrong
|
|
9
|
+
cost is worse than a missing one, which is why unknown models return
|
|
10
|
+
None rather than a guessed price (see cost_for_model below).
|
|
11
|
+
|
|
12
|
+
Prices verified from public provider pricing pages, ~Sep 2026.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
# (input_price_per_token, output_price_per_token)
|
|
18
|
+
_PRICING = {
|
|
19
|
+
# OpenAI
|
|
20
|
+
"gpt-4o": (2.50 / 1_000_000, 10.00 / 1_000_000),
|
|
21
|
+
"gpt-4o-mini": (0.15 / 1_000_000, 0.60 / 1_000_000),
|
|
22
|
+
"gpt-4.1": (2.00 / 1_000_000, 8.00 / 1_000_000),
|
|
23
|
+
"o3-mini": (1.10 / 1_000_000, 4.40 / 1_000_000),
|
|
24
|
+
|
|
25
|
+
# Anthropic
|
|
26
|
+
"claude-opus-4.8": (5.00 / 1_000_000, 25.00 / 1_000_000),
|
|
27
|
+
"claude-sonnet-4.6": (3.00 / 1_000_000, 15.00 / 1_000_000),
|
|
28
|
+
"claude-haiku-4.5": (1.00 / 1_000_000, 5.00 / 1_000_000),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def cost_for_model(model_name: str, prompt_tokens: int, completion_tokens: int) -> Optional[float]:
|
|
33
|
+
"""
|
|
34
|
+
Returns the dollar cost for this many tokens on this model, or None
|
|
35
|
+
if the model isn't in our table. Callers should treat None as "cost
|
|
36
|
+
unknown" (log_step already defaults cost_usd to 0.0 for that case)
|
|
37
|
+
rather than falling back to a guessed number.
|
|
38
|
+
"""
|
|
39
|
+
if not model_name:
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
# Match loosely — providers often return versioned names
|
|
43
|
+
# ("gpt-4o-2024-05-13") that won't exactly match our table keys.
|
|
44
|
+
normalized = model_name.lower()
|
|
45
|
+
for key, (in_price, out_price) in _PRICING.items():
|
|
46
|
+
if key in normalized:
|
|
47
|
+
return (prompt_tokens * in_price) + (completion_tokens * out_price)
|
|
48
|
+
|
|
49
|
+
return None
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sythra
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Runtime intelligence for AI agents: find the first point where a run went wrong, what it cost, and how to fix it.
|
|
5
|
+
Author-email: Sythra <sythra.hq@gmail.com>
|
|
6
|
+
Project-URL: Homepage, https://sythra.vercel.app
|
|
7
|
+
Project-URL: Repository, https://github.com/sythrahq/sythra
|
|
8
|
+
Keywords: ai,agents,observability,llm,debugging
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
Requires-Dist: httpx>=0.24
|
|
14
|
+
|
|
15
|
+
# Sythra
|
|
16
|
+
|
|
17
|
+
Runtime intelligence for AI agents. When a run goes wrong, Sythra finds the first point where it diverged, shows what it cascaded into, what it cost, and what to do about it.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
pip install sythra
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Quick start
|
|
26
|
+
|
|
27
|
+
Create an agent in your dashboard at https://sythra.vercel.app to get an API key, then wrap your agent function:
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from sythra import Sythra
|
|
31
|
+
|
|
32
|
+
sythra = Sythra(api_key="YOUR_API_KEY", agent_name="my-agent")
|
|
33
|
+
|
|
34
|
+
@sythra.observe
|
|
35
|
+
def run_agent(question):
|
|
36
|
+
... # your agent code
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Each call is recorded with its outcome, duration and steps. Works with LangChain, LangGraph, CrewAI and the OpenAI Agents SDK.
|
|
40
|
+
|
|
41
|
+
## Privacy
|
|
42
|
+
|
|
43
|
+
Sythra records what your agent did (tools, models, cost, outcome), not what it said. Prompt and response content is never captured unless you turn it on.
|
|
44
|
+
|
|
45
|
+
## Links
|
|
46
|
+
|
|
47
|
+
- Dashboard: https://sythra.vercel.app
|
|
48
|
+
- Source: https://github.com/sythrahq/sythra
|
|
49
|
+
- Support: sythra.hq@gmail.com
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
sythra/__init__.py
|
|
4
|
+
sythra/client.py
|
|
5
|
+
sythra/crewai_listener.py
|
|
6
|
+
sythra/errors.py
|
|
7
|
+
sythra/langchain_handler.py
|
|
8
|
+
sythra/openai_agents_listener.py
|
|
9
|
+
sythra/pricing.py
|
|
10
|
+
sythra.egg-info/PKG-INFO
|
|
11
|
+
sythra.egg-info/SOURCES.txt
|
|
12
|
+
sythra.egg-info/dependency_links.txt
|
|
13
|
+
sythra.egg-info/requires.txt
|
|
14
|
+
sythra.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
httpx>=0.24
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sythra
|