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 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ from .client import Sythra
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ __all__ = ["Sythra"]
@@ -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
+ httpx>=0.24
@@ -0,0 +1 @@
1
+ sythra