axonpush 0.0.1__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.
axonpush/__init__.py ADDED
@@ -0,0 +1,48 @@
1
+ """AxonPush — Python SDK for real-time event infrastructure for AI agent systems."""
2
+
3
+ from axonpush._tracing import TraceContext, get_or_create_trace
4
+ from axonpush._version import __version__
5
+ from axonpush.client import AsyncAxonPush, AxonPush
6
+ from axonpush.exceptions import (
7
+ AuthenticationError,
8
+ AxonPushError,
9
+ ForbiddenError,
10
+ NotFoundError,
11
+ RateLimitError,
12
+ ServerError,
13
+ ValidationError,
14
+ )
15
+ from axonpush.models.apps import App
16
+ from axonpush.models.channels import Channel
17
+ from axonpush.models.events import Event, EventType
18
+ from axonpush.models.traces import TraceListItem, TraceSummary
19
+ from axonpush.models.webhooks import DeliveryStatus, WebhookDelivery, WebhookEndpoint
20
+
21
+ __all__ = [
22
+ # Clients
23
+ "AxonPush",
24
+ "AsyncAxonPush",
25
+ # Models
26
+ "App",
27
+ "Channel",
28
+ "DeliveryStatus",
29
+ "Event",
30
+ "EventType",
31
+ "TraceListItem",
32
+ "TraceSummary",
33
+ "WebhookDelivery",
34
+ "WebhookEndpoint",
35
+ # Tracing
36
+ "TraceContext",
37
+ "get_or_create_trace",
38
+ # Exceptions
39
+ "AuthenticationError",
40
+ "AxonPushError",
41
+ "ForbiddenError",
42
+ "NotFoundError",
43
+ "RateLimitError",
44
+ "ServerError",
45
+ "ValidationError",
46
+ # Meta
47
+ "__version__",
48
+ ]
axonpush/_auth.py ADDED
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class AuthConfig:
5
+ """Immutable auth configuration. Thread-safe (read-only after construction)."""
6
+
7
+ __slots__ = ("api_key", "tenant_id", "base_url")
8
+
9
+ def __init__(
10
+ self,
11
+ api_key: str,
12
+ tenant_id: str,
13
+ base_url: str,
14
+ ) -> None:
15
+ self.api_key = api_key
16
+ self.tenant_id = tenant_id
17
+ self.base_url = base_url.rstrip("/")
18
+
19
+ def headers(self) -> dict[str, str]:
20
+ return {
21
+ "X-API-Key": self.api_key,
22
+ "x-tenant-id": self.tenant_id,
23
+ "Content-Type": "application/json",
24
+ }
axonpush/_http.py ADDED
@@ -0,0 +1,121 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import contextmanager
4
+ from typing import Any, Dict, Generator, Iterator, Optional
5
+
6
+ import httpx
7
+ from httpx_sse import EventSource, connect_sse, aconnect_sse
8
+
9
+ from axonpush._auth import AuthConfig
10
+ from axonpush.exceptions import (
11
+ AuthenticationError,
12
+ AxonPushError,
13
+ ForbiddenError,
14
+ NotFoundError,
15
+ RateLimitError,
16
+ ServerError,
17
+ ValidationError,
18
+ )
19
+
20
+ _ERROR_MAP: Dict[int, type] = {
21
+ 400: ValidationError,
22
+ 401: AuthenticationError,
23
+ 403: ForbiddenError,
24
+ 404: NotFoundError,
25
+ 429: RateLimitError,
26
+ }
27
+
28
+
29
+ def _raise_for_status(response: httpx.Response) -> None:
30
+ if response.is_success:
31
+ return
32
+
33
+ status = response.status_code
34
+ try:
35
+ body = response.json()
36
+ message = body.get("message", response.text)
37
+ if isinstance(message, list):
38
+ message = "; ".join(str(m) for m in message)
39
+ except Exception:
40
+ message = response.text or f"HTTP {status}"
41
+
42
+ if status == 429:
43
+ retry_after_raw = response.headers.get("Retry-After")
44
+ retry_after = float(retry_after_raw) if retry_after_raw else None
45
+ raise RateLimitError(str(message), retry_after=retry_after)
46
+
47
+ exc_cls = _ERROR_MAP.get(status)
48
+ if exc_cls is not None:
49
+ raise exc_cls(str(message), status_code=status)
50
+
51
+ if status >= 500:
52
+ raise ServerError(str(message), status_code=status)
53
+
54
+ raise AxonPushError(str(message), status_code=status)
55
+
56
+
57
+ class SyncTransport:
58
+ """Synchronous HTTP transport backed by httpx.Client."""
59
+
60
+ def __init__(self, auth: AuthConfig, timeout: float = 30.0) -> None:
61
+ self._auth = auth
62
+ self._client = httpx.Client(
63
+ base_url=auth.base_url,
64
+ headers=auth.headers(),
65
+ timeout=timeout,
66
+ )
67
+
68
+ def request(
69
+ self,
70
+ method: str,
71
+ path: str,
72
+ *,
73
+ json: Any = None,
74
+ params: Optional[Dict[str, Any]] = None,
75
+ ) -> Any:
76
+ response = self._client.request(method, path, json=json, params=params)
77
+ _raise_for_status(response)
78
+ if not response.content:
79
+ return None
80
+ return response.json()
81
+
82
+ @contextmanager
83
+ def stream_sse(
84
+ self, path: str, params: Optional[Dict[str, Any]] = None
85
+ ) -> Generator[EventSource, None, None]:
86
+ with connect_sse(
87
+ self._client, "GET", path, params=params or {}
88
+ ) as event_source:
89
+ yield event_source
90
+
91
+ def close(self) -> None:
92
+ self._client.close()
93
+
94
+
95
+ class AsyncTransport:
96
+ """Asynchronous HTTP transport backed by httpx.AsyncClient."""
97
+
98
+ def __init__(self, auth: AuthConfig, timeout: float = 30.0) -> None:
99
+ self._auth = auth
100
+ self._client = httpx.AsyncClient(
101
+ base_url=auth.base_url,
102
+ headers=auth.headers(),
103
+ timeout=timeout,
104
+ )
105
+
106
+ async def request(
107
+ self,
108
+ method: str,
109
+ path: str,
110
+ *,
111
+ json: Any = None,
112
+ params: Optional[Dict[str, Any]] = None,
113
+ ) -> Any:
114
+ response = await self._client.request(method, path, json=json, params=params)
115
+ _raise_for_status(response)
116
+ if not response.content:
117
+ return None
118
+ return response.json()
119
+
120
+ async def close(self) -> None:
121
+ await self._client.aclose()
axonpush/_tracing.py ADDED
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+ import uuid
5
+ from contextvars import ContextVar
6
+ from dataclasses import dataclass, field
7
+
8
+ _current_trace: ContextVar[TraceContext | None] = ContextVar("_current_trace", default=None)
9
+
10
+
11
+ @dataclass
12
+ class TraceContext:
13
+ """Holds a trace_id and generates sequential span IDs.
14
+
15
+ Thread-safe via a lock on the span counter.
16
+ Task-safe via contextvars (each asyncio Task inherits its own copy).
17
+ """
18
+
19
+ trace_id: str = field(default_factory=lambda: f"tr_{uuid.uuid4().hex[:16]}")
20
+ _span_counter: int = field(default=0, repr=False)
21
+ _lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
22
+
23
+ def next_span_id(self) -> str:
24
+ with self._lock:
25
+ self._span_counter += 1
26
+ return f"sp_{self.trace_id[3:]}_{self._span_counter:04d}"
27
+
28
+
29
+ def get_or_create_trace(trace_id: str | None = None) -> TraceContext:
30
+ """Get the current trace from context, or create a new one.
31
+
32
+ If *trace_id* is provided, always creates a fresh context with that ID.
33
+ If *trace_id* is None and a context already exists, returns it.
34
+ Otherwise creates a new context with an auto-generated ID.
35
+ """
36
+ if trace_id is not None:
37
+ ctx = TraceContext(trace_id=trace_id)
38
+ _current_trace.set(ctx)
39
+ return ctx
40
+
41
+ existing = _current_trace.get()
42
+ if existing is not None:
43
+ return existing
44
+
45
+ ctx = TraceContext()
46
+ _current_trace.set(ctx)
47
+ return ctx
48
+
49
+
50
+ def current_trace() -> TraceContext | None:
51
+ """Return the current trace context, or None if not set."""
52
+ return _current_trace.get()
axonpush/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
axonpush/client.py ADDED
@@ -0,0 +1,102 @@
1
+ from __future__ import annotations
2
+
3
+ from axonpush._auth import AuthConfig
4
+ from axonpush._http import AsyncTransport, SyncTransport
5
+ from axonpush.realtime.websocket import AsyncWebSocketClient, WebSocketClient
6
+ from axonpush.resources.apps import AppsResource, AsyncAppsResource
7
+ from axonpush.resources.channels import AsyncChannelsResource, ChannelsResource
8
+ from axonpush.resources.events import AsyncEventsResource, EventsResource
9
+ from axonpush.resources.traces import AsyncTracesResource, TracesResource
10
+ from axonpush.resources.webhooks import AsyncWebhooksResource, WebhooksResource
11
+
12
+
13
+ class AxonPush:
14
+ """Synchronous AxonPush client. Thread-safe.
15
+
16
+ Usage::
17
+
18
+ with AxonPush(api_key="ak_...", tenant_id="1", base_url="https://...") as client:
19
+ event = client.events.publish(
20
+ "web_search", {"query": "AI agents"}, channel_id=1,
21
+ agent_id="researcher", event_type="agent.tool_call.start",
22
+ )
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ api_key: str,
28
+ tenant_id: str,
29
+ *,
30
+ base_url: str,
31
+ timeout: float = 30.0,
32
+ ) -> None:
33
+ self._auth = AuthConfig(api_key, tenant_id, base_url)
34
+ self._transport = SyncTransport(self._auth, timeout)
35
+
36
+ self.events = EventsResource(self._transport)
37
+ self.channels = ChannelsResource(self._transport)
38
+ self.apps = AppsResource(self._transport)
39
+ self.webhooks = WebhooksResource(self._transport)
40
+ self.traces = TracesResource(self._transport)
41
+
42
+ def connect_websocket(self) -> WebSocketClient:
43
+ """Create and connect a Socket.IO WebSocket client."""
44
+ ws = WebSocketClient(self._auth)
45
+ ws.connect()
46
+ return ws
47
+
48
+ def close(self) -> None:
49
+ """Close the underlying HTTP transport."""
50
+ self._transport.close()
51
+
52
+ def __enter__(self) -> AxonPush:
53
+ return self
54
+
55
+ def __exit__(self, *args: object) -> None:
56
+ self.close()
57
+
58
+
59
+ class AsyncAxonPush:
60
+ """Asynchronous AxonPush client. Task-safe.
61
+
62
+ Usage::
63
+
64
+ async with AsyncAxonPush(api_key="ak_...", tenant_id="1", base_url="https://...") as client:
65
+ event = await client.events.publish(
66
+ "web_search", {"query": "AI agents"}, channel_id=1,
67
+ agent_id="researcher", event_type="agent.tool_call.start",
68
+ )
69
+ """
70
+
71
+ def __init__(
72
+ self,
73
+ api_key: str,
74
+ tenant_id: str,
75
+ *,
76
+ base_url: str,
77
+ timeout: float = 30.0,
78
+ ) -> None:
79
+ self._auth = AuthConfig(api_key, tenant_id, base_url)
80
+ self._transport = AsyncTransport(self._auth, timeout)
81
+
82
+ self.events = AsyncEventsResource(self._transport)
83
+ self.channels = AsyncChannelsResource(self._transport)
84
+ self.apps = AsyncAppsResource(self._transport)
85
+ self.webhooks = AsyncWebhooksResource(self._transport)
86
+ self.traces = AsyncTracesResource(self._transport)
87
+
88
+ async def connect_websocket(self) -> AsyncWebSocketClient:
89
+ """Create and connect an async Socket.IO WebSocket client."""
90
+ ws = AsyncWebSocketClient(self._auth)
91
+ await ws.connect()
92
+ return ws
93
+
94
+ async def close(self) -> None:
95
+ """Close the underlying HTTP transport."""
96
+ await self._transport.close()
97
+
98
+ async def __aenter__(self) -> AsyncAxonPush:
99
+ return self
100
+
101
+ async def __aexit__(self, *args: object) -> None:
102
+ await self.close()
axonpush/exceptions.py ADDED
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class AxonPushError(Exception):
5
+ """Base exception for all AxonPush SDK errors."""
6
+
7
+ def __init__(self, message: str, status_code: int | None = None) -> None:
8
+ self.status_code = status_code
9
+ super().__init__(message)
10
+
11
+
12
+ class AuthenticationError(AxonPushError):
13
+ """Raised when the API key or JWT token is invalid or missing (HTTP 401)."""
14
+
15
+
16
+ class ForbiddenError(AxonPushError):
17
+ """Raised when the authenticated user lacks permission (HTTP 403)."""
18
+
19
+
20
+ class NotFoundError(AxonPushError):
21
+ """Raised when the requested resource does not exist (HTTP 404)."""
22
+
23
+
24
+ class ValidationError(AxonPushError):
25
+ """Raised when the request body fails validation (HTTP 400)."""
26
+
27
+
28
+ class RateLimitError(AxonPushError):
29
+ """Raised when the rate limit is exceeded (HTTP 429)."""
30
+
31
+ def __init__(self, message: str, retry_after: float | None = None) -> None:
32
+ self.retry_after = retry_after
33
+ super().__init__(message, status_code=429)
34
+
35
+
36
+ class ServerError(AxonPushError):
37
+ """Raised when the server returns a 5xx error."""
@@ -0,0 +1,7 @@
1
+ # Integrations are lazily imported to avoid requiring optional dependencies.
2
+ # Import them directly:
3
+ #
4
+ # from axonpush.integrations.langchain import AxonPushCallbackHandler
5
+ # from axonpush.integrations.openai_agents import AxonPushRunHooks
6
+ # from axonpush.integrations.anthropic import AxonPushAnthropicTracer
7
+ # from axonpush.integrations.crewai import AxonPushCrewCallbacks
@@ -0,0 +1,184 @@
1
+ """Anthropic/Claude integration for AxonPush.
2
+
3
+ Requires: ``pip install axonpush[anthropic]``
4
+
5
+ Unlike LangChain/CrewAI, the Anthropic SDK has no callback hooks. This
6
+ integration wraps ``messages.create()`` calls to automatically emit events
7
+ for tool_use blocks, text responses, and conversation turns.
8
+
9
+ Usage::
10
+
11
+ from axonpush import AxonPush
12
+ from axonpush.integrations.anthropic import AxonPushAnthropicTracer
13
+
14
+ client = AxonPush(api_key="ak_...", tenant_id="1")
15
+ tracer = AxonPushAnthropicTracer(client, channel_id=1)
16
+
17
+ # Sync
18
+ response = tracer.create_message(
19
+ anthropic_client,
20
+ model="claude-sonnet-4-20250514",
21
+ messages=[{"role": "user", "content": "Hello"}],
22
+ tools=[...],
23
+ )
24
+
25
+ # Async
26
+ response = await tracer.acreate_message(async_anthropic_client, ...)
27
+ """
28
+ from __future__ import annotations
29
+
30
+ from typing import Any, Dict, Optional
31
+
32
+ try:
33
+ import anthropic # noqa: F401 — verify the package is installed
34
+ except ImportError:
35
+ raise ImportError(
36
+ "Anthropic integration requires the 'anthropic' extra. "
37
+ "Install it with: pip install axonpush[anthropic]"
38
+ ) from None
39
+
40
+ from axonpush._tracing import get_or_create_trace
41
+ from axonpush.models.events import EventType
42
+
43
+ from typing import TYPE_CHECKING
44
+
45
+ if TYPE_CHECKING:
46
+ from axonpush.client import AsyncAxonPush, AxonPush
47
+
48
+
49
+ class AxonPushAnthropicTracer:
50
+ """Wraps Anthropic API calls to emit AxonPush trace events."""
51
+
52
+ def __init__(
53
+ self,
54
+ client: AxonPush | AsyncAxonPush,
55
+ channel_id: int,
56
+ *,
57
+ agent_id: str = "claude",
58
+ trace_id: Optional[str] = None,
59
+ ) -> None:
60
+ self._client = client
61
+ self._channel_id = channel_id
62
+ self._agent_id = agent_id
63
+ self._trace = get_or_create_trace(trace_id)
64
+
65
+ def create_message(self, anthropic_client: Any, **kwargs: Any) -> Any:
66
+ """Wrap a sync ``anthropic_client.messages.create()`` call with tracing."""
67
+ self._emit_sync(
68
+ "conversation.turn",
69
+ EventType.AGENT_START,
70
+ {
71
+ "model": kwargs.get("model"),
72
+ "message_count": len(kwargs.get("messages", [])),
73
+ },
74
+ )
75
+
76
+ response = anthropic_client.messages.create(**kwargs)
77
+ self._process_response(response)
78
+ return response
79
+
80
+ async def acreate_message(self, anthropic_client: Any, **kwargs: Any) -> Any:
81
+ """Wrap an async ``anthropic_client.messages.create()`` call with tracing."""
82
+ await self._emit_async(
83
+ "conversation.turn",
84
+ EventType.AGENT_START,
85
+ {
86
+ "model": kwargs.get("model"),
87
+ "message_count": len(kwargs.get("messages", [])),
88
+ },
89
+ )
90
+
91
+ response = await anthropic_client.messages.create(**kwargs)
92
+ await self._aprocess_response(response)
93
+ return response
94
+
95
+ def send_tool_result(self, tool_use_id: str, result: Any) -> None:
96
+ """Emit a tool_call.end event when you send a tool result back."""
97
+ self._emit_sync(
98
+ "tool.result",
99
+ EventType.AGENT_TOOL_CALL_END,
100
+ {"tool_use_id": tool_use_id, "result_preview": str(result)[:500]},
101
+ )
102
+
103
+ async def asend_tool_result(self, tool_use_id: str, result: Any) -> None:
104
+ """Async variant of send_tool_result."""
105
+ await self._emit_async(
106
+ "tool.result",
107
+ EventType.AGENT_TOOL_CALL_END,
108
+ {"tool_use_id": tool_use_id, "result_preview": str(result)[:500]},
109
+ )
110
+
111
+ # -- Internal --
112
+
113
+ def _process_response(self, response: Any) -> None:
114
+ for block in getattr(response, "content", []):
115
+ block_type = getattr(block, "type", None)
116
+ if block_type == "tool_use":
117
+ self._emit_sync(
118
+ f"tool.{block.name}.start",
119
+ EventType.AGENT_TOOL_CALL_START,
120
+ {
121
+ "tool_name": block.name,
122
+ "tool_use_id": block.id,
123
+ "input": _truncate(block.input),
124
+ },
125
+ )
126
+ elif block_type == "text":
127
+ self._emit_sync(
128
+ "agent.response",
129
+ EventType.AGENT_MESSAGE,
130
+ {"text_length": len(block.text)},
131
+ )
132
+
133
+ async def _aprocess_response(self, response: Any) -> None:
134
+ for block in getattr(response, "content", []):
135
+ block_type = getattr(block, "type", None)
136
+ if block_type == "tool_use":
137
+ await self._emit_async(
138
+ f"tool.{block.name}.start",
139
+ EventType.AGENT_TOOL_CALL_START,
140
+ {
141
+ "tool_name": block.name,
142
+ "tool_use_id": block.id,
143
+ "input": _truncate(block.input),
144
+ },
145
+ )
146
+ elif block_type == "text":
147
+ await self._emit_async(
148
+ "agent.response",
149
+ EventType.AGENT_MESSAGE,
150
+ {"text_length": len(block.text)},
151
+ )
152
+
153
+ def _emit_sync(
154
+ self, identifier: str, event_type: EventType, payload: Dict[str, Any]
155
+ ) -> None:
156
+ self._client.events.publish( # type: ignore[union-attr]
157
+ identifier=identifier,
158
+ payload=payload,
159
+ channel_id=self._channel_id,
160
+ agent_id=self._agent_id,
161
+ trace_id=self._trace.trace_id,
162
+ span_id=self._trace.next_span_id(),
163
+ event_type=event_type,
164
+ metadata={"framework": "anthropic"},
165
+ )
166
+
167
+ async def _emit_async(
168
+ self, identifier: str, event_type: EventType, payload: Dict[str, Any]
169
+ ) -> None:
170
+ await self._client.events.publish( # type: ignore[union-attr]
171
+ identifier=identifier,
172
+ payload=payload,
173
+ channel_id=self._channel_id,
174
+ agent_id=self._agent_id,
175
+ trace_id=self._trace.trace_id,
176
+ span_id=self._trace.next_span_id(),
177
+ event_type=event_type,
178
+ metadata={"framework": "anthropic"},
179
+ )
180
+
181
+
182
+ def _truncate(obj: Any, max_len: int = 500) -> Any:
183
+ s = str(obj)
184
+ return s[:max_len] if len(s) > max_len else s