aforo-agent-metering 0.3.2__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.
@@ -0,0 +1,4 @@
1
+ include README.md
2
+ include LICENSE
3
+ recursive-include aforo_agent_metering *.py
4
+ include aforo_agent_metering/py.typed
@@ -0,0 +1,145 @@
1
+ Metadata-Version: 2.4
2
+ Name: aforo-agent-metering
3
+ Version: 0.3.2
4
+ Summary: Aforo AI Agent Metering SDK — instrument agent runs (sessions, steps, capability calls) for billing and analytics
5
+ Author-email: Aforo <engineering@aforo.ai>
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/aforoai/SDKs
8
+ Project-URL: Repository, https://github.com/aforoai/SDKs
9
+ Project-URL: Documentation, https://docs.aforo.ai
10
+ Keywords: aforo,ai-agent,agent,metering,billing,langchain,llamaindex,crewai,autogen,fastapi
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: Apache Software License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Software Development :: Libraries
20
+ Requires-Python: >=3.9
21
+ Description-Content-Type: text/markdown
22
+ Provides-Extra: aiohttp
23
+ Requires-Dist: aiohttp>=3.8; extra == "aiohttp"
24
+ Provides-Extra: httpx
25
+ Requires-Dist: httpx>=0.24; extra == "httpx"
26
+ Provides-Extra: test
27
+ Requires-Dist: pytest>=7; extra == "test"
28
+ Requires-Dist: pytest-asyncio>=0.21; extra == "test"
29
+
30
+ # aforo-agent-metering
31
+
32
+ Meter AI agent runs from Python: record each capability invocation and reasoning step as an `AI_AGENT` usage event, buffered and batched to Aforo. For agent runtimes such as LangChain, LlamaIndex, CrewAI, AutoGen and FastAPI-hosted agents. Python sibling of `@aforoai/agent-metering` (Node).
33
+
34
+ **Version:** 0.3.2 · [Changelog](CHANGELOG.md) · [User guide](USER_GUIDE.md)
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ pip install aforo-agent-metering
40
+ # pick an async HTTP client (optional — stdlib urllib is the fallback):
41
+ pip install "aforo-agent-metering[aiohttp]"
42
+ pip install "aforo-agent-metering[httpx]"
43
+ ```
44
+
45
+ From source:
46
+
47
+ ```bash
48
+ git clone https://github.com/aforoai/SDKs.git
49
+ cd SDKs/aforo-metering-sdks/python-agent
50
+ pip install -e .
51
+ ```
52
+
53
+ ## Quickstart
54
+
55
+ ```python
56
+ import asyncio
57
+ import os
58
+ from aforo_agent_metering import AforoAgentClient
59
+
60
+ async def main():
61
+ client = AforoAgentClient(
62
+ tenant_id="tenant_xxx",
63
+ product_id="prod_ai_001",
64
+ api_key=os.environ["AFORO_API_KEY"],
65
+ # ingestor_url defaults to https://api.aforo.ai
66
+ )
67
+ await client.start() # begin the periodic flush task
68
+
69
+ client.record_capability( # synchronous: buffers the event
70
+ capability_name="summarize_email",
71
+ agent_id="agt_001",
72
+ customer_id="cust_42",
73
+ session_id="sess_1",
74
+ input_tokens=320,
75
+ output_tokens=84,
76
+ execution_duration_ms=510,
77
+ )
78
+
79
+ await client.shutdown() # final flush
80
+
81
+ asyncio.run(main())
82
+ ```
83
+
84
+ Events POST to `https://api.aforo.ai/v1/ingest/batch` as `{"events": [...]}` with `X-API-Key: <api_key>` and `X-Tenant-Id: <tenant_id>`. No `Authorization` header is sent.
85
+
86
+ ## Decorator
87
+
88
+ ```python
89
+ from aforo_agent_metering import AforoAgentClient, wrap_capability_handler
90
+
91
+ client = AforoAgentClient(...)
92
+
93
+ @wrap_capability_handler(client, capability_name="summarize_email")
94
+ async def summarize_email(text: str, *, agent_id: str, session_id: str, customer_id: str):
95
+ ...
96
+ ```
97
+
98
+ The decorator times the handler, records one invocation when it finishes, and re-raises whatever the handler raised. Status: returned → `SUCCESS`; `TimeoutError` → `TIMEOUT`; any other exception → `ERROR`; `asyncio.CancelledError` / `KeyboardInterrupt` / `SystemExit` → `CANCELLED`. The handler must be `async def`.
99
+
100
+ ## Configuration
101
+
102
+ | Option | Type | Default | What it does |
103
+ |---|---|---|---|
104
+ | `tenant_id` | `str` | — (required) | Aforo tenant; sent as `X-Tenant-Id`. |
105
+ | `product_id` | `str` | — (required) | AI_AGENT product the events bill against; stamped in `metadata.productId`. |
106
+ | `api_key` | `str` | — (required) | Aforo API key, sent as `X-API-Key`. |
107
+ | `ingestor_url` | `str` | `https://api.aforo.ai` | Host; `/v1/ingest/batch` is appended. |
108
+ | `default_customer_id` | `str?` | `None` | `customerId` when a call passes none. Falls back to `agent_id`. |
109
+ | `product_type` | `str` | `"AI_AGENT"` | Top-level `productType` on every event (trimmed + upper-cased). Override per event with `record_capability(..., product_type=...)`. |
110
+ | `flush_count` | `int` | `50` | Buffered events that trigger a flush (clamped to 1000, the ingestor's batch limit). |
111
+ | `flush_interval_sec` | `float` | `5.0` | Periodic flush cadence. Requires `await start()`. |
112
+ | `max_retries` | `int` | `3` | Attempts per batch. |
113
+ | `on_error` | `Callable[[Exception], None]?` | logs a WARN | Called on a permanent batch failure. |
114
+ | `on_drop` | `Callable[[list[dict], str], None]?` | `None` | Opt-in hook for events the SDK loses. |
115
+ | `post_fn` | `PostFn?` | aiohttp → httpx → urllib | HTTP transport override, for tests. |
116
+
117
+ ### Execution status
118
+
119
+ `execution_status` is trimmed and upper-cased. Accepted values: `SUCCESS`, `PARTIAL`, `TIMEOUT`, `ERROR`, `VALIDATION_FAILED`, `FAILED`, `FAILURE`, `CANCELLED`, `PENDING`, `BLOCKED`, `HITL_REQUIRED` (exported as `EXECUTION_STATUSES`). A blank or unknown value is logged and left off; the event is still sent. OUTCOME_BASED rate plans bill each event at the weight set for its status — see the [repository README](../README.md#reporting-the-request-outcome-executionstatus).
120
+
121
+ ### Retries and dropped events
122
+
123
+ Retry: 5xx, 408, 429 and network errors, with `1s / 2s` backoff; a 429 waits for its `Retry-After`. Any other 4xx is not retried.
124
+
125
+ `record_capability` and `record_step` never raise for event content. Every event the SDK cannot deliver is counted in `client.dropped_count`, WARN-logged, and passed to `on_drop(events, reason)`. Dropped events keep their idempotency keys.
126
+
127
+ | `reason` | When |
128
+ |---|---|
129
+ | `retry_exhausted` | The batch still failed after `max_retries` attempts. |
130
+ | `rejected` | A non-retryable 4xx for the batch (`on_error` gets the ingestor's `errors[].message`), a batch that could not be JSON-encoded, or events the ingestor rejected individually in a `202`. For a `202`, only the events the response names by index are passed to the hook; if it names none, they are counted only. |
131
+ | `invalid` | The SDK refused the event before buffering it: blank `capability_name` / `agent_id` (or `session_id` / `step_type` for a step), `agent_id` over 36 characters, `session_id`, `customer_id` or `capability_name` over 64. Nothing passed to `record_*` or to the decorator is truncated. The one exception: a capability name that `wrap_capability_handler` reads from the wrapped call's `capability_name` kwarg is truncated to 64 and the event is still sent. The WARN names the field and limit; it is logged on the first occurrence and then every 1000th. |
132
+
133
+ ## Wire format
134
+
135
+ Each event carries top-level `customerId`, `metricName` (`ai_agent.capability_invocations` or `ai_agent.steps`), `quantity` 1, `occurredAt`, `idempotencyKey` (`agent:<uuid4>`, minted once when the event is created), `productType`, `agentId`, `sessionId`, `executionDurationMs`, optional `executionStatus`, and `metadata` with `capability_name` (snake_case — the key the ingestor maps to the per-capability billing dimension), token counts and `sdkVersion`.
136
+
137
+ The endpoint path and body shape are checked against `contract/ingest-contract.json` by this package's tests.
138
+
139
+ ## Walk me through it
140
+
141
+ Install → construct → record → confirm the event in Aforo: **[USER_GUIDE.md](USER_GUIDE.md)**. A runnable example is in [`examples/hello_agent`](examples/hello_agent).
142
+
143
+ ## What this doesn't cover
144
+
145
+ This SDK emits usage events. It does not send session heartbeats, check entitlements or quotas, or price anything — rate plans and per-capability pricing are configured in the Aforo console. `record_capability` / `record_step` only buffer; without `await start()` or an explicit `await flush()` nothing is sent until the buffer reaches `flush_count` inside a running event loop.
@@ -0,0 +1,116 @@
1
+ # aforo-agent-metering
2
+
3
+ Meter AI agent runs from Python: record each capability invocation and reasoning step as an `AI_AGENT` usage event, buffered and batched to Aforo. For agent runtimes such as LangChain, LlamaIndex, CrewAI, AutoGen and FastAPI-hosted agents. Python sibling of `@aforoai/agent-metering` (Node).
4
+
5
+ **Version:** 0.3.2 · [Changelog](CHANGELOG.md) · [User guide](USER_GUIDE.md)
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install aforo-agent-metering
11
+ # pick an async HTTP client (optional — stdlib urllib is the fallback):
12
+ pip install "aforo-agent-metering[aiohttp]"
13
+ pip install "aforo-agent-metering[httpx]"
14
+ ```
15
+
16
+ From source:
17
+
18
+ ```bash
19
+ git clone https://github.com/aforoai/SDKs.git
20
+ cd SDKs/aforo-metering-sdks/python-agent
21
+ pip install -e .
22
+ ```
23
+
24
+ ## Quickstart
25
+
26
+ ```python
27
+ import asyncio
28
+ import os
29
+ from aforo_agent_metering import AforoAgentClient
30
+
31
+ async def main():
32
+ client = AforoAgentClient(
33
+ tenant_id="tenant_xxx",
34
+ product_id="prod_ai_001",
35
+ api_key=os.environ["AFORO_API_KEY"],
36
+ # ingestor_url defaults to https://api.aforo.ai
37
+ )
38
+ await client.start() # begin the periodic flush task
39
+
40
+ client.record_capability( # synchronous: buffers the event
41
+ capability_name="summarize_email",
42
+ agent_id="agt_001",
43
+ customer_id="cust_42",
44
+ session_id="sess_1",
45
+ input_tokens=320,
46
+ output_tokens=84,
47
+ execution_duration_ms=510,
48
+ )
49
+
50
+ await client.shutdown() # final flush
51
+
52
+ asyncio.run(main())
53
+ ```
54
+
55
+ Events POST to `https://api.aforo.ai/v1/ingest/batch` as `{"events": [...]}` with `X-API-Key: <api_key>` and `X-Tenant-Id: <tenant_id>`. No `Authorization` header is sent.
56
+
57
+ ## Decorator
58
+
59
+ ```python
60
+ from aforo_agent_metering import AforoAgentClient, wrap_capability_handler
61
+
62
+ client = AforoAgentClient(...)
63
+
64
+ @wrap_capability_handler(client, capability_name="summarize_email")
65
+ async def summarize_email(text: str, *, agent_id: str, session_id: str, customer_id: str):
66
+ ...
67
+ ```
68
+
69
+ The decorator times the handler, records one invocation when it finishes, and re-raises whatever the handler raised. Status: returned → `SUCCESS`; `TimeoutError` → `TIMEOUT`; any other exception → `ERROR`; `asyncio.CancelledError` / `KeyboardInterrupt` / `SystemExit` → `CANCELLED`. The handler must be `async def`.
70
+
71
+ ## Configuration
72
+
73
+ | Option | Type | Default | What it does |
74
+ |---|---|---|---|
75
+ | `tenant_id` | `str` | — (required) | Aforo tenant; sent as `X-Tenant-Id`. |
76
+ | `product_id` | `str` | — (required) | AI_AGENT product the events bill against; stamped in `metadata.productId`. |
77
+ | `api_key` | `str` | — (required) | Aforo API key, sent as `X-API-Key`. |
78
+ | `ingestor_url` | `str` | `https://api.aforo.ai` | Host; `/v1/ingest/batch` is appended. |
79
+ | `default_customer_id` | `str?` | `None` | `customerId` when a call passes none. Falls back to `agent_id`. |
80
+ | `product_type` | `str` | `"AI_AGENT"` | Top-level `productType` on every event (trimmed + upper-cased). Override per event with `record_capability(..., product_type=...)`. |
81
+ | `flush_count` | `int` | `50` | Buffered events that trigger a flush (clamped to 1000, the ingestor's batch limit). |
82
+ | `flush_interval_sec` | `float` | `5.0` | Periodic flush cadence. Requires `await start()`. |
83
+ | `max_retries` | `int` | `3` | Attempts per batch. |
84
+ | `on_error` | `Callable[[Exception], None]?` | logs a WARN | Called on a permanent batch failure. |
85
+ | `on_drop` | `Callable[[list[dict], str], None]?` | `None` | Opt-in hook for events the SDK loses. |
86
+ | `post_fn` | `PostFn?` | aiohttp → httpx → urllib | HTTP transport override, for tests. |
87
+
88
+ ### Execution status
89
+
90
+ `execution_status` is trimmed and upper-cased. Accepted values: `SUCCESS`, `PARTIAL`, `TIMEOUT`, `ERROR`, `VALIDATION_FAILED`, `FAILED`, `FAILURE`, `CANCELLED`, `PENDING`, `BLOCKED`, `HITL_REQUIRED` (exported as `EXECUTION_STATUSES`). A blank or unknown value is logged and left off; the event is still sent. OUTCOME_BASED rate plans bill each event at the weight set for its status — see the [repository README](../README.md#reporting-the-request-outcome-executionstatus).
91
+
92
+ ### Retries and dropped events
93
+
94
+ Retry: 5xx, 408, 429 and network errors, with `1s / 2s` backoff; a 429 waits for its `Retry-After`. Any other 4xx is not retried.
95
+
96
+ `record_capability` and `record_step` never raise for event content. Every event the SDK cannot deliver is counted in `client.dropped_count`, WARN-logged, and passed to `on_drop(events, reason)`. Dropped events keep their idempotency keys.
97
+
98
+ | `reason` | When |
99
+ |---|---|
100
+ | `retry_exhausted` | The batch still failed after `max_retries` attempts. |
101
+ | `rejected` | A non-retryable 4xx for the batch (`on_error` gets the ingestor's `errors[].message`), a batch that could not be JSON-encoded, or events the ingestor rejected individually in a `202`. For a `202`, only the events the response names by index are passed to the hook; if it names none, they are counted only. |
102
+ | `invalid` | The SDK refused the event before buffering it: blank `capability_name` / `agent_id` (or `session_id` / `step_type` for a step), `agent_id` over 36 characters, `session_id`, `customer_id` or `capability_name` over 64. Nothing passed to `record_*` or to the decorator is truncated. The one exception: a capability name that `wrap_capability_handler` reads from the wrapped call's `capability_name` kwarg is truncated to 64 and the event is still sent. The WARN names the field and limit; it is logged on the first occurrence and then every 1000th. |
103
+
104
+ ## Wire format
105
+
106
+ Each event carries top-level `customerId`, `metricName` (`ai_agent.capability_invocations` or `ai_agent.steps`), `quantity` 1, `occurredAt`, `idempotencyKey` (`agent:<uuid4>`, minted once when the event is created), `productType`, `agentId`, `sessionId`, `executionDurationMs`, optional `executionStatus`, and `metadata` with `capability_name` (snake_case — the key the ingestor maps to the per-capability billing dimension), token counts and `sdkVersion`.
107
+
108
+ The endpoint path and body shape are checked against `contract/ingest-contract.json` by this package's tests.
109
+
110
+ ## Walk me through it
111
+
112
+ Install → construct → record → confirm the event in Aforo: **[USER_GUIDE.md](USER_GUIDE.md)**. A runnable example is in [`examples/hello_agent`](examples/hello_agent).
113
+
114
+ ## What this doesn't cover
115
+
116
+ This SDK emits usage events. It does not send session heartbeats, check entitlements or quotas, or price anything — rate plans and per-capability pricing are configured in the Aforo console. `record_capability` / `record_step` only buffer; without `await start()` or an explicit `await flush()` nothing is sent until the buffer reaches `flush_count` inside a running event loop.
@@ -0,0 +1,40 @@
1
+ """
2
+ aforo-agent-metering — Aforo AI Agent Metering SDK for Python.
3
+
4
+ Instrument AI agent runtimes (LangChain, LlamaIndex, CrewAI, AutoGen,
5
+ FastAPI-hosted agents) to bill capability invocations, steps, and
6
+ sessions on Aforo's AI_AGENT product type.
7
+
8
+ Usage:
9
+ from aforo_agent_metering import AforoAgentClient, wrap_capability_handler
10
+
11
+ client = AforoAgentClient(
12
+ tenant_id="tenant_xxx",
13
+ product_id="prod_ai_001",
14
+ api_key=os.environ["AFORO_API_KEY"],
15
+ ingestor_url="https://api.aforo.ai",
16
+ )
17
+ await client.start() # begin periodic flush
18
+
19
+ @wrap_capability_handler(client, capability_name="summarize_email")
20
+ async def summarize(text: str, *, agent_id: str, session_id: str, customer_id: str):
21
+ ...
22
+ """
23
+
24
+ from .client import (
25
+ EXECUTION_STATUSES,
26
+ AforoAgentClient,
27
+ ExecutionStatus,
28
+ __version__,
29
+ normalize_execution_status,
30
+ )
31
+ from .decorators import wrap_capability_handler
32
+
33
+ __all__ = [
34
+ "AforoAgentClient",
35
+ "EXECUTION_STATUSES",
36
+ "ExecutionStatus",
37
+ "__version__",
38
+ "normalize_execution_status",
39
+ "wrap_capability_handler",
40
+ ]
@@ -0,0 +1,43 @@
1
+ """
2
+ Bounded in-memory event buffer.
3
+
4
+ Client pushes events with :meth:`EventBuffer.add`; buffer signals when
5
+ it's at or above the configured high-water mark so the client can flush.
6
+ Drain returns a copy and empties the buffer atomically (single-threaded
7
+ asyncio; no lock needed because ``list.copy`` / ``list.clear`` are
8
+ individually atomic under the GIL and callers hold the event loop
9
+ between them).
10
+
11
+ The buffer is intentionally dumb — no timing, no HTTP, no retry.
12
+ Those live in :mod:`aforo_agent_metering.transport` and
13
+ :mod:`aforo_agent_metering.client`.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Any, Dict, List
19
+
20
+
21
+ class EventBuffer:
22
+ """Bounded buffer for outgoing usage events."""
23
+
24
+ def __init__(self, max_events: int = 50) -> None:
25
+ if max_events <= 0:
26
+ raise ValueError("max_events must be > 0")
27
+ self._events: List[Dict[str, Any]] = []
28
+ self.max_events = max_events
29
+
30
+ def add(self, event: Dict[str, Any]) -> bool:
31
+ """Append an event. Returns True when the buffer is at or above
32
+ ``max_events`` and the caller should flush now."""
33
+ self._events.append(event)
34
+ return len(self._events) >= self.max_events
35
+
36
+ def drain(self) -> List[Dict[str, Any]]:
37
+ """Return and clear all buffered events atomically."""
38
+ events = self._events[:]
39
+ self._events.clear()
40
+ return events
41
+
42
+ def __len__(self) -> int:
43
+ return len(self._events)