opentoken-sdk 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.
- opentoken_sdk-0.1.0/PKG-INFO +155 -0
- opentoken_sdk-0.1.0/README.md +126 -0
- opentoken_sdk-0.1.0/pyproject.toml +57 -0
- opentoken_sdk-0.1.0/setup.cfg +4 -0
- opentoken_sdk-0.1.0/src/opentoken/__init__.py +5 -0
- opentoken_sdk-0.1.0/src/opentoken/constants.py +19 -0
- opentoken_sdk-0.1.0/src/opentoken/integrations/__init__.py +14 -0
- opentoken_sdk-0.1.0/src/opentoken/integrations/agents.py +181 -0
- opentoken_sdk-0.1.0/src/opentoken/reporter.py +438 -0
- opentoken_sdk-0.1.0/src/opentoken/schemas.py +32 -0
- opentoken_sdk-0.1.0/src/opentoken_sdk.egg-info/PKG-INFO +155 -0
- opentoken_sdk-0.1.0/src/opentoken_sdk.egg-info/SOURCES.txt +13 -0
- opentoken_sdk-0.1.0/src/opentoken_sdk.egg-info/dependency_links.txt +1 -0
- opentoken_sdk-0.1.0/src/opentoken_sdk.egg-info/requires.txt +13 -0
- opentoken_sdk-0.1.0/src/opentoken_sdk.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: opentoken-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: OpenToken Python SDK
|
|
5
|
+
Author-email: Nicole Chen <nchen55555@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://opentoken.bid
|
|
8
|
+
Keywords: observability,llm,openai,anthropic,gemini,tokens,usage
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
16
|
+
Classifier: Topic :: System :: Monitoring
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
Requires-Dist: httpx
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest; extra == "dev"
|
|
22
|
+
Requires-Dist: build; extra == "dev"
|
|
23
|
+
Requires-Dist: twine; extra == "dev"
|
|
24
|
+
Provides-Extra: agents
|
|
25
|
+
Requires-Dist: openai-agents>=0.17; extra == "agents"
|
|
26
|
+
Provides-Extra: live
|
|
27
|
+
Requires-Dist: openai-agents>=0.17; extra == "live"
|
|
28
|
+
Requires-Dist: openai>=1.0; extra == "live"
|
|
29
|
+
|
|
30
|
+
# Open Token Python SDK
|
|
31
|
+
|
|
32
|
+
## Using `UsageReporter` directly
|
|
33
|
+
|
|
34
|
+
If you want to report usage from any provider, use `UsageReporter` directly:
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from opentoken import UsageReporter
|
|
38
|
+
|
|
39
|
+
reporter = UsageReporter(
|
|
40
|
+
open_token_api_key=os.environ["OPENTOKEN_API_KEY"],
|
|
41
|
+
tags={"service": "checkout-api", "env": "prod"},
|
|
42
|
+
batch=True, # default; set False to POST one event at a time
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
response = your_provider_client.chat.completions.create(...)
|
|
46
|
+
reporter.report_usage(response=response, latency_ms=..., tags={"workflow_id": "..."})
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`report_usage(...)` enqueues the report on a bounded in-memory queue and
|
|
50
|
+
returns immediately (~microseconds on the caller's thread). A background
|
|
51
|
+
daemon thread drains the queue and POSTs to `/v1/usage:batch`. You don't
|
|
52
|
+
manage the thread — it's started lazily on your first call and dies with
|
|
53
|
+
the process.
|
|
54
|
+
|
|
55
|
+
## OpenAI Agents SDK integration
|
|
56
|
+
|
|
57
|
+
If you're using the OpenAI Agents SDK, register the tracing bridge once at
|
|
58
|
+
startup and every `Runner.run(...)` will forward its LLM spans to
|
|
59
|
+
OpenToken. You never call `report_usage` yourself.
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from opentoken import UsageReporter, with_tags
|
|
63
|
+
from opentoken.integrations.agents import register_trace
|
|
64
|
+
from agents import Agent, Runner
|
|
65
|
+
|
|
66
|
+
# One-time setup — static tags apply to every event this reporter sends.
|
|
67
|
+
reporter = UsageReporter(open_token_api_key=..., tags={"env": "prod"})
|
|
68
|
+
register_trace(reporter)
|
|
69
|
+
|
|
70
|
+
sales_agent = Agent(name="sales_agent", model="gpt-5.5", instructions="...")
|
|
71
|
+
|
|
72
|
+
# Per-request handler — dynamic tags scoped to this one call.
|
|
73
|
+
async def handle(customer_id, question):
|
|
74
|
+
with with_tags(customer_id=customer_id, agent_name="sales"):
|
|
75
|
+
return await Runner.run(sales_agent, question)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Every event that flows out of that `Runner.run` lands with the merged tag
|
|
79
|
+
set — reporter defaults + whatever's active in the surrounding `with_tags`
|
|
80
|
+
block.
|
|
81
|
+
|
|
82
|
+
### `with_tags(**tags)` — dynamic per-request tags
|
|
83
|
+
|
|
84
|
+
`with_tags` is the customer-facing knob for tags that vary per request:
|
|
85
|
+
`customer_id`, `session_id`, `workflow`, whatever you want. It's a
|
|
86
|
+
context manager backed by `contextvars`, so:
|
|
87
|
+
|
|
88
|
+
- **Async-safe.** Concurrent tasks each maintain their own tag scope —
|
|
89
|
+
no cross-request bleed.
|
|
90
|
+
- **Nested.** Inner blocks inherit outer tags and override on collisions.
|
|
91
|
+
- **Works with both flows.** Whether the event is emitted by the Agents
|
|
92
|
+
integration or by a manual `reporter.report_usage(...)` call, the same
|
|
93
|
+
ambient stack is read.
|
|
94
|
+
|
|
95
|
+
### Tag precedence (highest wins)
|
|
96
|
+
|
|
97
|
+
For any event, tags merge in this order:
|
|
98
|
+
|
|
99
|
+
1. `UsageReporter(tags=...)` — reporter defaults, static, process-wide
|
|
100
|
+
2. `with_tags(...)` — ambient, request-scoped
|
|
101
|
+
3. `report_usage(tags=...)` — per-call, most specific
|
|
102
|
+
|
|
103
|
+
Later layers override earlier ones on key collisions.
|
|
104
|
+
|
|
105
|
+
### What lands in `transactions.tags`
|
|
106
|
+
|
|
107
|
+
Given the example above, one event would look like:
|
|
108
|
+
|
|
109
|
+
```json
|
|
110
|
+
{
|
|
111
|
+
"env": "prod", // reporter default
|
|
112
|
+
"customer_id": "cus_123", // from with_tags
|
|
113
|
+
"agent_name": "sales" // from with_tags
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
No auto-injected tags. What you write is what lands.
|
|
118
|
+
|
|
119
|
+
## Failure modes
|
|
120
|
+
|
|
121
|
+
- **HTTP failure (5xx, timeout, network blip).** The batch is retried up
|
|
122
|
+
to 3 times with exponential backoff + jitter, then dropped and logged.
|
|
123
|
+
The worker keeps running.
|
|
124
|
+
- **Unexpected exception in the worker.** The worker's main loop catches
|
|
125
|
+
any exception, logs it, and continues. If the thread does die despite
|
|
126
|
+
that, the next `report_usage()` call starts a fresh one; only the event
|
|
127
|
+
in-flight at the time of the crash is lost — queued events are
|
|
128
|
+
preserved.
|
|
129
|
+
- **Process death (SIGKILL, crash, sudden exit).** The in-memory queue is
|
|
130
|
+
lost. Long-lived servers are fine; short scripts should call
|
|
131
|
+
`reporter.flush()` before exit, or rely on the built-in `atexit` flush
|
|
132
|
+
(bounded to 5 seconds so slow OpenToken never hangs your process).
|
|
133
|
+
|
|
134
|
+
Enable `logging.getLogger("opentoken")` to see retries, drops, and worker
|
|
135
|
+
restarts. Call `reporter.stats()` any time to inspect `sent`, `dropped`,
|
|
136
|
+
`retried`, `batches_flushed`, `worker_restarts`, `queue_depth`.
|
|
137
|
+
|
|
138
|
+
## Response shape
|
|
139
|
+
|
|
140
|
+
Both `/v1/usage` and `/v1/usage:batch` return the same positional bool
|
|
141
|
+
array — one element per input event:
|
|
142
|
+
|
|
143
|
+
```json
|
|
144
|
+
{"results": [true, false, true]}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Each `true` means the event was accepted; each `false` means it failed
|
|
148
|
+
(unknown model, missing usage block, etc.). The SDK counts `true` events
|
|
149
|
+
toward `sent` and retries `false` events up to `max_retries` times, then
|
|
150
|
+
counts still-failing events toward `dropped`.
|
|
151
|
+
|
|
152
|
+
HTTP status is `200` for any well-formed request — individual event
|
|
153
|
+
failures are surfaced inside `results`, not at the HTTP layer. The
|
|
154
|
+
endpoints return `4xx` only for whole-request problems (auth, malformed
|
|
155
|
+
JSON, oversized batch).
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# Open Token Python SDK
|
|
2
|
+
|
|
3
|
+
## Using `UsageReporter` directly
|
|
4
|
+
|
|
5
|
+
If you want to report usage from any provider, use `UsageReporter` directly:
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
from opentoken import UsageReporter
|
|
9
|
+
|
|
10
|
+
reporter = UsageReporter(
|
|
11
|
+
open_token_api_key=os.environ["OPENTOKEN_API_KEY"],
|
|
12
|
+
tags={"service": "checkout-api", "env": "prod"},
|
|
13
|
+
batch=True, # default; set False to POST one event at a time
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
response = your_provider_client.chat.completions.create(...)
|
|
17
|
+
reporter.report_usage(response=response, latency_ms=..., tags={"workflow_id": "..."})
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`report_usage(...)` enqueues the report on a bounded in-memory queue and
|
|
21
|
+
returns immediately (~microseconds on the caller's thread). A background
|
|
22
|
+
daemon thread drains the queue and POSTs to `/v1/usage:batch`. You don't
|
|
23
|
+
manage the thread — it's started lazily on your first call and dies with
|
|
24
|
+
the process.
|
|
25
|
+
|
|
26
|
+
## OpenAI Agents SDK integration
|
|
27
|
+
|
|
28
|
+
If you're using the OpenAI Agents SDK, register the tracing bridge once at
|
|
29
|
+
startup and every `Runner.run(...)` will forward its LLM spans to
|
|
30
|
+
OpenToken. You never call `report_usage` yourself.
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from opentoken import UsageReporter, with_tags
|
|
34
|
+
from opentoken.integrations.agents import register_trace
|
|
35
|
+
from agents import Agent, Runner
|
|
36
|
+
|
|
37
|
+
# One-time setup — static tags apply to every event this reporter sends.
|
|
38
|
+
reporter = UsageReporter(open_token_api_key=..., tags={"env": "prod"})
|
|
39
|
+
register_trace(reporter)
|
|
40
|
+
|
|
41
|
+
sales_agent = Agent(name="sales_agent", model="gpt-5.5", instructions="...")
|
|
42
|
+
|
|
43
|
+
# Per-request handler — dynamic tags scoped to this one call.
|
|
44
|
+
async def handle(customer_id, question):
|
|
45
|
+
with with_tags(customer_id=customer_id, agent_name="sales"):
|
|
46
|
+
return await Runner.run(sales_agent, question)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Every event that flows out of that `Runner.run` lands with the merged tag
|
|
50
|
+
set — reporter defaults + whatever's active in the surrounding `with_tags`
|
|
51
|
+
block.
|
|
52
|
+
|
|
53
|
+
### `with_tags(**tags)` — dynamic per-request tags
|
|
54
|
+
|
|
55
|
+
`with_tags` is the customer-facing knob for tags that vary per request:
|
|
56
|
+
`customer_id`, `session_id`, `workflow`, whatever you want. It's a
|
|
57
|
+
context manager backed by `contextvars`, so:
|
|
58
|
+
|
|
59
|
+
- **Async-safe.** Concurrent tasks each maintain their own tag scope —
|
|
60
|
+
no cross-request bleed.
|
|
61
|
+
- **Nested.** Inner blocks inherit outer tags and override on collisions.
|
|
62
|
+
- **Works with both flows.** Whether the event is emitted by the Agents
|
|
63
|
+
integration or by a manual `reporter.report_usage(...)` call, the same
|
|
64
|
+
ambient stack is read.
|
|
65
|
+
|
|
66
|
+
### Tag precedence (highest wins)
|
|
67
|
+
|
|
68
|
+
For any event, tags merge in this order:
|
|
69
|
+
|
|
70
|
+
1. `UsageReporter(tags=...)` — reporter defaults, static, process-wide
|
|
71
|
+
2. `with_tags(...)` — ambient, request-scoped
|
|
72
|
+
3. `report_usage(tags=...)` — per-call, most specific
|
|
73
|
+
|
|
74
|
+
Later layers override earlier ones on key collisions.
|
|
75
|
+
|
|
76
|
+
### What lands in `transactions.tags`
|
|
77
|
+
|
|
78
|
+
Given the example above, one event would look like:
|
|
79
|
+
|
|
80
|
+
```json
|
|
81
|
+
{
|
|
82
|
+
"env": "prod", // reporter default
|
|
83
|
+
"customer_id": "cus_123", // from with_tags
|
|
84
|
+
"agent_name": "sales" // from with_tags
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
No auto-injected tags. What you write is what lands.
|
|
89
|
+
|
|
90
|
+
## Failure modes
|
|
91
|
+
|
|
92
|
+
- **HTTP failure (5xx, timeout, network blip).** The batch is retried up
|
|
93
|
+
to 3 times with exponential backoff + jitter, then dropped and logged.
|
|
94
|
+
The worker keeps running.
|
|
95
|
+
- **Unexpected exception in the worker.** The worker's main loop catches
|
|
96
|
+
any exception, logs it, and continues. If the thread does die despite
|
|
97
|
+
that, the next `report_usage()` call starts a fresh one; only the event
|
|
98
|
+
in-flight at the time of the crash is lost — queued events are
|
|
99
|
+
preserved.
|
|
100
|
+
- **Process death (SIGKILL, crash, sudden exit).** The in-memory queue is
|
|
101
|
+
lost. Long-lived servers are fine; short scripts should call
|
|
102
|
+
`reporter.flush()` before exit, or rely on the built-in `atexit` flush
|
|
103
|
+
(bounded to 5 seconds so slow OpenToken never hangs your process).
|
|
104
|
+
|
|
105
|
+
Enable `logging.getLogger("opentoken")` to see retries, drops, and worker
|
|
106
|
+
restarts. Call `reporter.stats()` any time to inspect `sent`, `dropped`,
|
|
107
|
+
`retried`, `batches_flushed`, `worker_restarts`, `queue_depth`.
|
|
108
|
+
|
|
109
|
+
## Response shape
|
|
110
|
+
|
|
111
|
+
Both `/v1/usage` and `/v1/usage:batch` return the same positional bool
|
|
112
|
+
array — one element per input event:
|
|
113
|
+
|
|
114
|
+
```json
|
|
115
|
+
{"results": [true, false, true]}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Each `true` means the event was accepted; each `false` means it failed
|
|
119
|
+
(unknown model, missing usage block, etc.). The SDK counts `true` events
|
|
120
|
+
toward `sent` and retries `false` events up to `max_retries` times, then
|
|
121
|
+
counts still-failing events toward `dropped`.
|
|
122
|
+
|
|
123
|
+
HTTP status is `200` for any well-formed request — individual event
|
|
124
|
+
failures are surfaced inside `results`, not at the HTTP layer. The
|
|
125
|
+
endpoints return `4xx` only for whole-request problems (auth, malformed
|
|
126
|
+
JSON, oversized batch).
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
# Distribution name on PyPI. Import name stays `opentoken` — the source
|
|
3
|
+
# tree at `src/opentoken/` is unchanged. Users do `pip install opentoken-sdk`
|
|
4
|
+
# and then `import opentoken`.
|
|
5
|
+
name = "opentoken-sdk"
|
|
6
|
+
version = "0.1.0"
|
|
7
|
+
description = "OpenToken Python SDK"
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [
|
|
12
|
+
{ name = "Nicole Chen", email = "nchen55555@gmail.com" },
|
|
13
|
+
]
|
|
14
|
+
keywords = ["observability", "llm", "openai", "anthropic", "gemini", "tokens", "usage"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
# `License ::` classifier intentionally omitted: PEP 639 (enforced by
|
|
19
|
+
# PyPI as of 2025) rejects uploads that declare a license via both
|
|
20
|
+
# the `license` field AND a `License ::` classifier. SPDX identifier
|
|
21
|
+
# lives on the `license` field above.
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Programming Language :: Python :: 3.10",
|
|
24
|
+
"Programming Language :: Python :: 3.11",
|
|
25
|
+
"Programming Language :: Python :: 3.12",
|
|
26
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
27
|
+
"Topic :: System :: Monitoring",
|
|
28
|
+
]
|
|
29
|
+
dependencies = [
|
|
30
|
+
"httpx"
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
dev = [
|
|
35
|
+
"pytest",
|
|
36
|
+
"build",
|
|
37
|
+
"twine",
|
|
38
|
+
]
|
|
39
|
+
# OpenAI Agents SDK integration — `opentoken.integrations.agents`.
|
|
40
|
+
agents = [
|
|
41
|
+
"openai-agents>=0.17",
|
|
42
|
+
]
|
|
43
|
+
# End-to-end live testing: real Agents SDK + real OpenAI + real backend.
|
|
44
|
+
live = [
|
|
45
|
+
"openai-agents>=0.17",
|
|
46
|
+
"openai>=1.0",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
[project.urls]
|
|
50
|
+
Homepage = "https://opentoken.bid"
|
|
51
|
+
|
|
52
|
+
[build-system]
|
|
53
|
+
requires = ["setuptools>=68"]
|
|
54
|
+
build-backend = "setuptools.build_meta"
|
|
55
|
+
|
|
56
|
+
[tool.setuptools.packages.find]
|
|
57
|
+
where = ["src"]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""SDK-wide constants."""
|
|
2
|
+
|
|
3
|
+
DEFAULT_OPENTOKEN_BASE_URL = "https://opentoken.bid"
|
|
4
|
+
|
|
5
|
+
# Batching defaults — internal defaults for UsageReporter. Both are
|
|
6
|
+
# overridable via the constructor; batching itself is opt-in via
|
|
7
|
+
# `batch=True` (which is the default).
|
|
8
|
+
DEFAULT_BATCH_SIZE = 100
|
|
9
|
+
DEFAULT_FLUSH_INTERVAL_MS = 500
|
|
10
|
+
|
|
11
|
+
# Retry defaults — bounded so a persistent poison batch can't stall
|
|
12
|
+
# the worker forever. See `_send_batch_with_retries`.
|
|
13
|
+
DEFAULT_MAX_RETRIES = 3
|
|
14
|
+
DEFAULT_RETRY_INITIAL_DELAY_S = 0.1
|
|
15
|
+
DEFAULT_RETRY_BACKOFF_MULTIPLIER = 4.0
|
|
16
|
+
|
|
17
|
+
# atexit flush timeout — bounded so a slow OpenToken can't hang a
|
|
18
|
+
# customer's Lambda/CLI at process exit.
|
|
19
|
+
DEFAULT_ATEXIT_FLUSH_TIMEOUT_S = 5.0
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Framework-specific bridges.
|
|
2
|
+
|
|
3
|
+
Each submodule is an integration with an agent/LLM framework whose
|
|
4
|
+
architecture prevents us from observing calls at the provider-SDK layer
|
|
5
|
+
(e.g. the framework owns the OpenAI client internally). The bridges are
|
|
6
|
+
thin wrappers around the framework's official observability hooks that
|
|
7
|
+
forward usage to `opentoken.UsageReporter`.
|
|
8
|
+
|
|
9
|
+
Each integration is guarded by an ImportError-friendly try/except at
|
|
10
|
+
its import site so the base SDK stays lightweight — customers install
|
|
11
|
+
the extra only if they use that framework:
|
|
12
|
+
|
|
13
|
+
pip install "opentoken[agents]" # OpenAI Agents SDK
|
|
14
|
+
"""
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""OpenAI Agents SDK integration.
|
|
2
|
+
|
|
3
|
+
The Agents SDK owns its own OpenAI client internally, so wrapping the
|
|
4
|
+
provider SDK doesn't help. Instead, we register a `TracingProcessor`
|
|
5
|
+
with the Agents SDK — it fires a callback on every span, including the
|
|
6
|
+
LLM-generation spans that carry `model` + `usage`. We forward each of
|
|
7
|
+
those to `UsageReporter.report_usage(...)`.
|
|
8
|
+
|
|
9
|
+
Usage (customer's app):
|
|
10
|
+
|
|
11
|
+
from opentoken import UsageReporter
|
|
12
|
+
from opentoken.integrations.agents import register_trace
|
|
13
|
+
|
|
14
|
+
reporter = UsageReporter(open_token_api_key=..., tags={"env": "prod"})
|
|
15
|
+
register_trace(reporter)
|
|
16
|
+
|
|
17
|
+
# customer's Agents SDK code is unchanged
|
|
18
|
+
result = await Runner.run(agent, "hi")
|
|
19
|
+
|
|
20
|
+
`register_trace(...)` registers the processor with the Agents SDK's
|
|
21
|
+
global trace registry. Every subsequent `Runner.run(...)` flows through
|
|
22
|
+
it.
|
|
23
|
+
|
|
24
|
+
Requires `openai-agents` — install with `pip install "opentoken[agents]"`.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import logging
|
|
30
|
+
from typing import TYPE_CHECKING, Any, Optional
|
|
31
|
+
|
|
32
|
+
from opentoken.reporter import UsageReporter
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from agents.tracing import Span, Trace
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger("opentoken")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class OpenTokenTracingProcessor:
|
|
41
|
+
"""Bridges Agents SDK trace spans to `UsageReporter`.
|
|
42
|
+
|
|
43
|
+
Implements the Agents SDK `TracingProcessor` interface (duck-typed to
|
|
44
|
+
avoid importing `agents` at module load — the import happens lazily
|
|
45
|
+
when the class is instantiated). Only LLM-generation spans emit
|
|
46
|
+
reports; agent/handoff/function/guardrail spans are ignored.
|
|
47
|
+
|
|
48
|
+
Thread-safety: `on_span_end` runs on the Agents SDK's tracing thread.
|
|
49
|
+
`report_usage` is thread-safe (non-blocking `queue.put_nowait`), so
|
|
50
|
+
the callback returns in microseconds.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, reporter: UsageReporter, extra_tags: Optional[dict[str, str]] = None):
|
|
54
|
+
# Import lazily to keep base `opentoken` importable without
|
|
55
|
+
# `openai-agents` installed. Users who never call register_trace()
|
|
56
|
+
# never pay the import cost.
|
|
57
|
+
from agents.tracing import GenerationSpanData, ResponseSpanData # noqa: F401
|
|
58
|
+
|
|
59
|
+
self._reporter = reporter
|
|
60
|
+
self._extra_tags = dict(extra_tags or {})
|
|
61
|
+
self._GenerationSpanData = GenerationSpanData
|
|
62
|
+
self._ResponseSpanData = ResponseSpanData
|
|
63
|
+
|
|
64
|
+
# ---- TracingProcessor interface (Agents SDK duck-types this) --------
|
|
65
|
+
|
|
66
|
+
def on_trace_start(self, trace: "Trace") -> None: # noqa: D401
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
def on_trace_end(self, trace: "Trace") -> None: # noqa: D401
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
def on_span_start(self, span: "Span") -> None: # noqa: D401
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
def on_span_end(self, span: "Span") -> None:
|
|
76
|
+
try:
|
|
77
|
+
model, usage = self._extract(span.span_data)
|
|
78
|
+
except Exception:
|
|
79
|
+
# A malformed / unexpected span shape must never crash the
|
|
80
|
+
# Agents SDK trace pipeline. Log and continue.
|
|
81
|
+
logger.exception("opentoken: failed to extract usage from span")
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
if not model or not isinstance(usage, dict):
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
# Only `extra_tags` set at `register_trace(...)` land here. Agent
|
|
88
|
+
# name, span type, and any per-run context (customer_id, workflow,
|
|
89
|
+
# ...) are the customer's job to add via `with_tags(...)` around
|
|
90
|
+
# their `Runner.run(...)` call — keeps this layer opinion-free and
|
|
91
|
+
# lets the customer decide what they want tagged. The endpoint is
|
|
92
|
+
# already distinguishable server-side via the model + usage shape,
|
|
93
|
+
# so we don't tag span_type either.
|
|
94
|
+
tags = dict(self._extra_tags) if self._extra_tags else None
|
|
95
|
+
self._reporter.report_usage(
|
|
96
|
+
model=model,
|
|
97
|
+
usage=usage,
|
|
98
|
+
latency_ms=_span_latency_ms(span),
|
|
99
|
+
client_request_id=f"agents-{span.span_id}",
|
|
100
|
+
tags=tags,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def shutdown(self) -> None:
|
|
104
|
+
# Best-effort flush so events survive process exit even without atexit.
|
|
105
|
+
try:
|
|
106
|
+
self._reporter.flush()
|
|
107
|
+
except Exception:
|
|
108
|
+
logger.exception("opentoken: flush on shutdown failed")
|
|
109
|
+
|
|
110
|
+
def force_flush(self) -> None:
|
|
111
|
+
try:
|
|
112
|
+
self._reporter.flush()
|
|
113
|
+
except Exception:
|
|
114
|
+
logger.exception("opentoken: force_flush failed")
|
|
115
|
+
|
|
116
|
+
# ---- Internals ------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
def _extract(self, data: Any) -> tuple[Optional[str], Optional[dict]]:
|
|
119
|
+
"""Pull (model, usage) out of a span's data payload.
|
|
120
|
+
|
|
121
|
+
Handles the two LLM-emitting span types today. Silently returns
|
|
122
|
+
(None, None) for other span types — agent/handoff/tool/etc.
|
|
123
|
+
"""
|
|
124
|
+
if isinstance(data, self._GenerationSpanData):
|
|
125
|
+
# GenerationSpanData is the legacy Chat Completions shape.
|
|
126
|
+
return data.model, data.usage
|
|
127
|
+
|
|
128
|
+
if isinstance(data, self._ResponseSpanData):
|
|
129
|
+
# ResponseSpanData wraps the OpenAI Responses API. Model lives
|
|
130
|
+
# on `response.model`; usage may be on `data.usage` (already
|
|
131
|
+
# extracted by Agents SDK) or on `response.usage`.
|
|
132
|
+
resp = data.response
|
|
133
|
+
model = getattr(resp, "model", None) if resp is not None else None
|
|
134
|
+
usage = data.usage
|
|
135
|
+
if usage is None and resp is not None:
|
|
136
|
+
raw = getattr(resp, "usage", None)
|
|
137
|
+
if raw is not None:
|
|
138
|
+
if hasattr(raw, "model_dump"):
|
|
139
|
+
try:
|
|
140
|
+
usage = raw.model_dump(mode="json")
|
|
141
|
+
except TypeError:
|
|
142
|
+
usage = raw.model_dump()
|
|
143
|
+
elif isinstance(raw, dict):
|
|
144
|
+
usage = raw
|
|
145
|
+
return model, usage
|
|
146
|
+
|
|
147
|
+
return None, None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _span_latency_ms(span: Any) -> Optional[int]:
|
|
151
|
+
"""Best-effort span duration in ms. Returns None if either timestamp is missing."""
|
|
152
|
+
started = getattr(span, "started_at", None)
|
|
153
|
+
ended = getattr(span, "ended_at", None)
|
|
154
|
+
if started is None or ended is None:
|
|
155
|
+
return None
|
|
156
|
+
try:
|
|
157
|
+
import datetime as _dt
|
|
158
|
+
if isinstance(started, str):
|
|
159
|
+
started = _dt.datetime.fromisoformat(started.replace("Z", "+00:00"))
|
|
160
|
+
if isinstance(ended, str):
|
|
161
|
+
ended = _dt.datetime.fromisoformat(ended.replace("Z", "+00:00"))
|
|
162
|
+
return int((ended - started).total_seconds() * 1000)
|
|
163
|
+
except Exception:
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def register_trace(
|
|
168
|
+
reporter: UsageReporter,
|
|
169
|
+
extra_tags: Optional[dict[str, str]] = None,
|
|
170
|
+
) -> OpenTokenTracingProcessor:
|
|
171
|
+
"""Register `OpenTokenTracingProcessor` with the Agents SDK.
|
|
172
|
+
|
|
173
|
+
After this call, every Agents SDK `Runner.run(...)` in this process
|
|
174
|
+
forwards its LLM spans to `reporter`. Returns the processor instance
|
|
175
|
+
so callers can hold a reference (rarely needed).
|
|
176
|
+
"""
|
|
177
|
+
from agents.tracing import add_trace_processor # lazy import
|
|
178
|
+
|
|
179
|
+
processor = OpenTokenTracingProcessor(reporter, extra_tags=extra_tags)
|
|
180
|
+
add_trace_processor(processor)
|
|
181
|
+
return processor
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
"""Usage reporting client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import atexit
|
|
6
|
+
import contextlib
|
|
7
|
+
import contextvars
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import queue
|
|
11
|
+
import random
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
import uuid
|
|
15
|
+
from typing import Iterator, Optional
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from opentoken.constants import (
|
|
20
|
+
DEFAULT_ATEXIT_FLUSH_TIMEOUT_S,
|
|
21
|
+
DEFAULT_BATCH_SIZE,
|
|
22
|
+
DEFAULT_FLUSH_INTERVAL_MS,
|
|
23
|
+
DEFAULT_MAX_RETRIES,
|
|
24
|
+
DEFAULT_OPENTOKEN_BASE_URL,
|
|
25
|
+
DEFAULT_RETRY_BACKOFF_MULTIPLIER,
|
|
26
|
+
DEFAULT_RETRY_INITIAL_DELAY_S,
|
|
27
|
+
)
|
|
28
|
+
from opentoken.schemas import UsageReport
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger("opentoken")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ---- Ambient (per-request) tags -----------------------------------------
|
|
34
|
+
#
|
|
35
|
+
# Populated by the `with_tags(...)` context manager below; read inside
|
|
36
|
+
# `report_usage(...)` and merged into every event emitted from within the
|
|
37
|
+
# block. Uses a ContextVar so concurrent async tasks each see their own
|
|
38
|
+
# scope — no cross-request bleed.
|
|
39
|
+
#
|
|
40
|
+
# The integrations layer (e.g. `integrations/agents.py`) calls
|
|
41
|
+
# `report_usage` on the customer's behalf, so ambient tags are the only
|
|
42
|
+
# way for the customer to attach per-request tags to Agents-emitted
|
|
43
|
+
# events without touching the reporter directly.
|
|
44
|
+
_ambient_tags: contextvars.ContextVar[dict[str, str]] = contextvars.ContextVar(
|
|
45
|
+
"opentoken_ambient_tags", default={},
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@contextlib.contextmanager
|
|
50
|
+
def with_tags(**tags: str) -> Iterator[None]:
|
|
51
|
+
"""Attach dynamic tags to every event emitted inside this block.
|
|
52
|
+
|
|
53
|
+
Async-safe via `contextvars` — concurrent tasks each maintain their
|
|
54
|
+
own tag scope. Nested calls stack: an inner `with_tags(...)` inherits
|
|
55
|
+
outer tags and adds/overrides on collisions.
|
|
56
|
+
|
|
57
|
+
Typical use — request-scoped tags that vary per call:
|
|
58
|
+
|
|
59
|
+
async def handle(customer_id, question):
|
|
60
|
+
with with_tags(customer_id=customer_id, session_id="s_abc"):
|
|
61
|
+
return await Runner.run(sales_agent, question)
|
|
62
|
+
|
|
63
|
+
Works with both the Agents SDK integration (which calls `report_usage`
|
|
64
|
+
on your behalf) and with manual `reporter.report_usage(...)` calls —
|
|
65
|
+
the same ambient stack is read either way.
|
|
66
|
+
|
|
67
|
+
All values are stringified so any JSON-serializable primitive is
|
|
68
|
+
accepted at the call site (numbers, bools, UUIDs, ...) and lands as a
|
|
69
|
+
string tag in `transactions.tags`.
|
|
70
|
+
"""
|
|
71
|
+
current = _ambient_tags.get()
|
|
72
|
+
merged = {**current, **{k: str(v) for k, v in tags.items()}}
|
|
73
|
+
token = _ambient_tags.set(merged)
|
|
74
|
+
try:
|
|
75
|
+
yield
|
|
76
|
+
finally:
|
|
77
|
+
_ambient_tags.reset(token)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class UsageReporter:
|
|
81
|
+
"""Enqueue usage reports and drain them from a daemon worker thread.
|
|
82
|
+
|
|
83
|
+
The producer (`report_usage`) never blocks — it pushes to a bounded
|
|
84
|
+
in-memory queue and returns in microseconds. A single background
|
|
85
|
+
thread drains the queue, batches events, POSTs them to OpenToken with
|
|
86
|
+
bounded retries, and re-enqueues in-flight events if the worker hits
|
|
87
|
+
an unexpected error.
|
|
88
|
+
|
|
89
|
+
Customers do not manage the thread. It starts lazily on the first
|
|
90
|
+
`report_usage` call and dies with the process. Call `stats()` to
|
|
91
|
+
inspect counters; enable `logging.getLogger("opentoken")` to see
|
|
92
|
+
warnings for retries, drops, and worker restarts.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
open_token_api_key: Optional[str] = None,
|
|
98
|
+
base_url: Optional[str] = None,
|
|
99
|
+
tags: Optional[dict[str, str]] = None,
|
|
100
|
+
batch: bool = True,
|
|
101
|
+
batch_size: int = DEFAULT_BATCH_SIZE,
|
|
102
|
+
flush_interval_ms: int = DEFAULT_FLUSH_INTERVAL_MS,
|
|
103
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
104
|
+
timeout: float = 2.0,
|
|
105
|
+
http_client=None,
|
|
106
|
+
max_queue_size: int = 1000,
|
|
107
|
+
) -> None:
|
|
108
|
+
self.open_token_api_key = open_token_api_key or os.environ.get("OPENTOKEN_API_KEY")
|
|
109
|
+
self.base_url = (base_url or os.environ.get("OPENTOKEN_BASE_URL") or DEFAULT_OPENTOKEN_BASE_URL).rstrip("/")
|
|
110
|
+
self.tags = dict(tags or {})
|
|
111
|
+
self.batch = batch
|
|
112
|
+
self.batch_size = max(1, batch_size)
|
|
113
|
+
self.flush_interval_ms = max(0, flush_interval_ms)
|
|
114
|
+
self.max_retries = max(1, max_retries)
|
|
115
|
+
self.timeout = timeout
|
|
116
|
+
self.http_client = http_client
|
|
117
|
+
|
|
118
|
+
self._queue: queue.Queue[Optional[UsageReport]] = queue.Queue(maxsize=max_queue_size)
|
|
119
|
+
self._worker: Optional[threading.Thread] = None
|
|
120
|
+
self._worker_lock = threading.Lock()
|
|
121
|
+
self._in_flight_batch: Optional[list[UsageReport]] = None
|
|
122
|
+
self._stats = {
|
|
123
|
+
"sent": 0,
|
|
124
|
+
"dropped": 0,
|
|
125
|
+
"retried": 0,
|
|
126
|
+
"batches_flushed": 0,
|
|
127
|
+
"worker_restarts": 0,
|
|
128
|
+
}
|
|
129
|
+
self._closed = False
|
|
130
|
+
|
|
131
|
+
atexit.register(self._atexit_flush)
|
|
132
|
+
|
|
133
|
+
def report_usage(
|
|
134
|
+
self,
|
|
135
|
+
response: Optional[dict] = None,
|
|
136
|
+
model: Optional[str] = None,
|
|
137
|
+
usage: Optional[dict] = None,
|
|
138
|
+
parent_request_id: Optional[str] = None,
|
|
139
|
+
latency_ms: Optional[int] = None,
|
|
140
|
+
client_request_id: Optional[str] = None,
|
|
141
|
+
tags: Optional[dict[str, str]] = None,
|
|
142
|
+
) -> bool:
|
|
143
|
+
if not self.open_token_api_key:
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
# Tag merge order (highest wins on collision):
|
|
147
|
+
# 1. reporter defaults (self.tags) — static, process-wide
|
|
148
|
+
# 2. ambient (with_tags) — dynamic, request-scoped
|
|
149
|
+
# 3. per-call `tags` arg — most specific
|
|
150
|
+
# Integrations use #3 for tags they know deterministically about
|
|
151
|
+
# the span; customer-facing dynamic context flows through #2.
|
|
152
|
+
ambient = _ambient_tags.get()
|
|
153
|
+
merged_tags: Optional[dict[str, str]] = None
|
|
154
|
+
if self.tags or ambient or tags:
|
|
155
|
+
merged_tags = {}
|
|
156
|
+
if self.tags:
|
|
157
|
+
merged_tags.update(self.tags)
|
|
158
|
+
if ambient:
|
|
159
|
+
merged_tags.update(ambient)
|
|
160
|
+
if tags:
|
|
161
|
+
merged_tags.update(tags)
|
|
162
|
+
|
|
163
|
+
# Auto-generate an idempotency key when the caller doesn't supply one.
|
|
164
|
+
# This survives full end-to-end retries (network hiccup → SDK resends
|
|
165
|
+
# the same event → server dedupes at the transactions layer) and
|
|
166
|
+
# Redis redeliveries inside the server (worker crash → XAUTOCLAIM
|
|
167
|
+
# hands the same event to another worker → ON CONFLICT drops the
|
|
168
|
+
# duplicate insert). Both properties rely on the key being stable
|
|
169
|
+
# for the lifetime of an event, so we generate it here at the
|
|
170
|
+
# earliest point where the event exists.
|
|
171
|
+
if client_request_id is None:
|
|
172
|
+
client_request_id = str(uuid.uuid4())
|
|
173
|
+
|
|
174
|
+
report = UsageReport(
|
|
175
|
+
response=response,
|
|
176
|
+
model=model,
|
|
177
|
+
usage=usage,
|
|
178
|
+
parent_request_id=parent_request_id,
|
|
179
|
+
latency_ms=latency_ms,
|
|
180
|
+
client_request_id=client_request_id,
|
|
181
|
+
tags=merged_tags,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
self._queue.put_nowait(report)
|
|
186
|
+
except queue.Full:
|
|
187
|
+
self._stats["dropped"] += 1
|
|
188
|
+
return False
|
|
189
|
+
|
|
190
|
+
self._ensure_worker()
|
|
191
|
+
return True
|
|
192
|
+
|
|
193
|
+
def stats(self) -> dict[str, int]:
|
|
194
|
+
"""Snapshot of counters. Safe to call from any thread."""
|
|
195
|
+
snapshot = dict(self._stats)
|
|
196
|
+
snapshot["queue_depth"] = self._queue.qsize()
|
|
197
|
+
return snapshot
|
|
198
|
+
|
|
199
|
+
def flush(self) -> None:
|
|
200
|
+
self._queue.join()
|
|
201
|
+
|
|
202
|
+
def close(self, timeout: Optional[float] = None) -> None:
|
|
203
|
+
if self._worker is None or not self._worker.is_alive():
|
|
204
|
+
return
|
|
205
|
+
try:
|
|
206
|
+
self._queue.put_nowait(None)
|
|
207
|
+
except queue.Full:
|
|
208
|
+
# Queue is full; drain one item so the pill can go in.
|
|
209
|
+
try:
|
|
210
|
+
self._queue.get_nowait()
|
|
211
|
+
self._queue.task_done()
|
|
212
|
+
self._queue.put_nowait(None)
|
|
213
|
+
except (queue.Empty, queue.Full):
|
|
214
|
+
pass
|
|
215
|
+
self._worker.join(timeout=timeout if timeout is not None else self.timeout)
|
|
216
|
+
self._worker = None
|
|
217
|
+
self._closed = True
|
|
218
|
+
|
|
219
|
+
# ---- Internal ------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
def _atexit_flush(self) -> None:
|
|
222
|
+
if self._closed:
|
|
223
|
+
return
|
|
224
|
+
try:
|
|
225
|
+
self.close(timeout=DEFAULT_ATEXIT_FLUSH_TIMEOUT_S)
|
|
226
|
+
except Exception:
|
|
227
|
+
pass
|
|
228
|
+
|
|
229
|
+
def _ensure_worker(self) -> None:
|
|
230
|
+
# Lock guards against two concurrent first-callers each starting a worker.
|
|
231
|
+
with self._worker_lock:
|
|
232
|
+
if self._worker is not None and self._worker.is_alive():
|
|
233
|
+
return
|
|
234
|
+
self._worker = threading.Thread(target=self._run_worker, daemon=True)
|
|
235
|
+
self._worker.start()
|
|
236
|
+
|
|
237
|
+
def _run_worker(self) -> None:
|
|
238
|
+
while True:
|
|
239
|
+
# Outer try catches unexpected exceptions (bugs). If we die
|
|
240
|
+
# mid-send, re-enqueue the in-flight batch and keep looping.
|
|
241
|
+
try:
|
|
242
|
+
# Block for at least one event.
|
|
243
|
+
first = self._queue.get()
|
|
244
|
+
if first is None:
|
|
245
|
+
self._queue.task_done()
|
|
246
|
+
return
|
|
247
|
+
|
|
248
|
+
batch = [first]
|
|
249
|
+
shutdown_pending = False
|
|
250
|
+
|
|
251
|
+
# Accumulate more events up to batch_size or flush_interval_ms.
|
|
252
|
+
if self.batch and self.batch_size > 1:
|
|
253
|
+
deadline = time.monotonic() + self.flush_interval_ms / 1000
|
|
254
|
+
while len(batch) < self.batch_size:
|
|
255
|
+
timeout = deadline - time.monotonic()
|
|
256
|
+
if timeout <= 0:
|
|
257
|
+
break
|
|
258
|
+
try:
|
|
259
|
+
item = self._queue.get(timeout=timeout)
|
|
260
|
+
except queue.Empty:
|
|
261
|
+
break
|
|
262
|
+
if item is None:
|
|
263
|
+
shutdown_pending = True
|
|
264
|
+
self._queue.task_done()
|
|
265
|
+
break
|
|
266
|
+
batch.append(item)
|
|
267
|
+
|
|
268
|
+
# Unified send path — picks endpoint + payload shape from
|
|
269
|
+
# `self.batch`. Manages its own stats, never raises.
|
|
270
|
+
self._in_flight_batch = batch
|
|
271
|
+
try:
|
|
272
|
+
self._send_with_retries(batch)
|
|
273
|
+
finally:
|
|
274
|
+
for _ in batch:
|
|
275
|
+
self._queue.task_done()
|
|
276
|
+
self._in_flight_batch = None
|
|
277
|
+
|
|
278
|
+
if shutdown_pending:
|
|
279
|
+
return
|
|
280
|
+
|
|
281
|
+
except Exception:
|
|
282
|
+
# A bug in the worker loop itself. Log, re-enqueue, continue.
|
|
283
|
+
logger.exception("worker unexpected error; re-enqueueing in-flight batch")
|
|
284
|
+
self._stats["worker_restarts"] += 1
|
|
285
|
+
if self._in_flight_batch is not None:
|
|
286
|
+
for item in reversed(self._in_flight_batch):
|
|
287
|
+
try:
|
|
288
|
+
self._queue.put_nowait(item)
|
|
289
|
+
except queue.Full:
|
|
290
|
+
self._stats["dropped"] += 1
|
|
291
|
+
self._in_flight_batch = None
|
|
292
|
+
|
|
293
|
+
def _send_with_retries(self, events: list[UsageReport]) -> None:
|
|
294
|
+
"""POST events with bounded retries. Manages stats; never raises.
|
|
295
|
+
|
|
296
|
+
Endpoint + payload shape depend on `self.batch`:
|
|
297
|
+
- batch=True → POST {"events": [...]} to /v1/usage:batch
|
|
298
|
+
- batch=False → POST single event body to /v1/usage
|
|
299
|
+
Both endpoints return `{"results": [bool, ...]}` (positional), so the
|
|
300
|
+
retry logic is identical: successful events are counted; failed
|
|
301
|
+
events shrink into `remaining` and are re-sent on the next attempt.
|
|
302
|
+
|
|
303
|
+
Retry semantics:
|
|
304
|
+
- 2xx with results → count sent; retry failed events only.
|
|
305
|
+
- 2xx without results → treat as full success (backwards-compat safety).
|
|
306
|
+
- 4xx (not 429) → drop remaining as a client error, no retry.
|
|
307
|
+
- 429 → honor Retry-After (or backoff jitter), retry all remaining.
|
|
308
|
+
- 5xx / network / timeout → retry all remaining.
|
|
309
|
+
- Exhausted → drop still-failing events.
|
|
310
|
+
"""
|
|
311
|
+
url = (
|
|
312
|
+
f"{self.base_url}/v1/usage:batch" if self.batch
|
|
313
|
+
else f"{self.base_url}/v1/usage"
|
|
314
|
+
)
|
|
315
|
+
remaining = list(events)
|
|
316
|
+
delay = DEFAULT_RETRY_INITIAL_DELAY_S
|
|
317
|
+
|
|
318
|
+
for attempt in range(1, self.max_retries + 1):
|
|
319
|
+
try:
|
|
320
|
+
payload = (
|
|
321
|
+
{"events": [r.to_payload() for r in remaining]} if self.batch
|
|
322
|
+
else remaining[0].to_payload()
|
|
323
|
+
)
|
|
324
|
+
resp = self._post(url, payload)
|
|
325
|
+
status = getattr(resp, "status_code", 0)
|
|
326
|
+
|
|
327
|
+
if 200 <= status < 300:
|
|
328
|
+
results = _parse_results(resp)
|
|
329
|
+
if results is None or len(results) != len(remaining):
|
|
330
|
+
# Server didn't return a positional bool array — treat as full success.
|
|
331
|
+
self._stats["sent"] += len(remaining)
|
|
332
|
+
self._stats["batches_flushed"] += 1
|
|
333
|
+
return
|
|
334
|
+
|
|
335
|
+
self._stats["sent"] += sum(1 for ok in results if ok)
|
|
336
|
+
failed = [ev for ev, ok in zip(remaining, results) if not ok]
|
|
337
|
+
if not failed:
|
|
338
|
+
self._stats["batches_flushed"] += 1
|
|
339
|
+
return
|
|
340
|
+
|
|
341
|
+
logger.warning(
|
|
342
|
+
"%d/%d events failed (attempt %d/%d), retrying failures",
|
|
343
|
+
len(failed), len(remaining), attempt, self.max_retries,
|
|
344
|
+
)
|
|
345
|
+
remaining = failed
|
|
346
|
+
# Fall through to backoff before next attempt.
|
|
347
|
+
|
|
348
|
+
elif status == 429:
|
|
349
|
+
if attempt >= self.max_retries:
|
|
350
|
+
logger.warning("429 rate-limited, retries exhausted; dropping %d events", len(remaining))
|
|
351
|
+
self._stats["dropped"] += len(remaining)
|
|
352
|
+
return
|
|
353
|
+
retry_after = _parse_retry_after(_get_header(resp, "Retry-After"))
|
|
354
|
+
sleep_for = retry_after if retry_after is not None else _jittered(delay)
|
|
355
|
+
logger.warning("429 rate-limited (attempt %d/%d), sleeping %.2fs", attempt, self.max_retries, sleep_for)
|
|
356
|
+
time.sleep(sleep_for)
|
|
357
|
+
self._stats["retried"] += len(remaining)
|
|
358
|
+
delay *= DEFAULT_RETRY_BACKOFF_MULTIPLIER
|
|
359
|
+
continue
|
|
360
|
+
|
|
361
|
+
elif 400 <= status < 500:
|
|
362
|
+
logger.warning("HTTP %d — client error, dropping %d events", status, len(remaining))
|
|
363
|
+
self._stats["dropped"] += len(remaining)
|
|
364
|
+
return
|
|
365
|
+
|
|
366
|
+
else:
|
|
367
|
+
# 5xx — fall through to retry
|
|
368
|
+
logger.warning("HTTP %d (attempt %d/%d)", status, attempt, self.max_retries)
|
|
369
|
+
|
|
370
|
+
except Exception as exc:
|
|
371
|
+
logger.warning("send failed (attempt %d/%d): %s", attempt, self.max_retries, exc)
|
|
372
|
+
|
|
373
|
+
if attempt < self.max_retries:
|
|
374
|
+
time.sleep(_jittered(delay))
|
|
375
|
+
self._stats["retried"] += len(remaining)
|
|
376
|
+
delay *= DEFAULT_RETRY_BACKOFF_MULTIPLIER
|
|
377
|
+
|
|
378
|
+
logger.warning("retries exhausted, dropping %d events", len(remaining))
|
|
379
|
+
self._stats["dropped"] += len(remaining)
|
|
380
|
+
|
|
381
|
+
def _post(self, url: str, payload: dict):
|
|
382
|
+
headers = {"Authorization": f"Bearer {self.open_token_api_key}"}
|
|
383
|
+
if self.http_client is not None:
|
|
384
|
+
return self.http_client.post(
|
|
385
|
+
url,
|
|
386
|
+
json=payload,
|
|
387
|
+
headers=headers,
|
|
388
|
+
timeout=self.timeout,
|
|
389
|
+
)
|
|
390
|
+
with httpx.Client(timeout=self.timeout) as client:
|
|
391
|
+
return client.post(url, json=payload, headers=headers)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _jittered(delay: float) -> float:
|
|
395
|
+
"""Full-jitter backoff — [0.5, 1.5) × delay — avoids retry storms."""
|
|
396
|
+
return delay * (0.5 + random.random())
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _get_header(resp, name: str) -> Optional[str]:
|
|
400
|
+
headers = getattr(resp, "headers", None)
|
|
401
|
+
if headers is None:
|
|
402
|
+
return None
|
|
403
|
+
try:
|
|
404
|
+
return headers.get(name)
|
|
405
|
+
except AttributeError:
|
|
406
|
+
return None
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _parse_retry_after(header: Optional[str]) -> Optional[float]:
|
|
410
|
+
"""Parse Retry-After. Only handles the integer-seconds form; HTTP-date is rare."""
|
|
411
|
+
if not header:
|
|
412
|
+
return None
|
|
413
|
+
try:
|
|
414
|
+
return float(header)
|
|
415
|
+
except (TypeError, ValueError):
|
|
416
|
+
return None
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _parse_results(resp) -> Optional[list[bool]]:
|
|
420
|
+
"""Extract `{"results": [bool, ...]}` from either endpoint's response.
|
|
421
|
+
|
|
422
|
+
Both `/v1/usage` and `/v1/usage:batch` return the same shape (single
|
|
423
|
+
endpoint returns a 1-element list). Returns None when the body is
|
|
424
|
+
missing, unparseable, or doesn't contain a bool array — callers treat
|
|
425
|
+
that as "assume full success" for backwards compatibility.
|
|
426
|
+
"""
|
|
427
|
+
if not hasattr(resp, "json"):
|
|
428
|
+
return None
|
|
429
|
+
try:
|
|
430
|
+
body = resp.json()
|
|
431
|
+
except Exception:
|
|
432
|
+
return None
|
|
433
|
+
if not isinstance(body, dict):
|
|
434
|
+
return None
|
|
435
|
+
results = body.get("results")
|
|
436
|
+
if not isinstance(results, list):
|
|
437
|
+
return None
|
|
438
|
+
return [bool(r) for r in results]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""SDK payload schemas."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class UsageReport:
|
|
11
|
+
response: Optional[dict] = None
|
|
12
|
+
model: Optional[str] = None
|
|
13
|
+
usage: Optional[dict] = None
|
|
14
|
+
parent_request_id: Optional[str] = None
|
|
15
|
+
latency_ms: Optional[int] = None
|
|
16
|
+
client_request_id: Optional[str] = None
|
|
17
|
+
tags: Optional[dict[str, str]] = None
|
|
18
|
+
|
|
19
|
+
def to_payload(self) -> dict:
|
|
20
|
+
return {
|
|
21
|
+
key: value
|
|
22
|
+
for key, value in {
|
|
23
|
+
"response": self.response,
|
|
24
|
+
"model": self.model,
|
|
25
|
+
"usage": self.usage,
|
|
26
|
+
"parent_request_id": self.parent_request_id,
|
|
27
|
+
"latency_ms": self.latency_ms,
|
|
28
|
+
"client_request_id": self.client_request_id,
|
|
29
|
+
"tags": self.tags,
|
|
30
|
+
}.items()
|
|
31
|
+
if value is not None
|
|
32
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: opentoken-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: OpenToken Python SDK
|
|
5
|
+
Author-email: Nicole Chen <nchen55555@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://opentoken.bid
|
|
8
|
+
Keywords: observability,llm,openai,anthropic,gemini,tokens,usage
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
16
|
+
Classifier: Topic :: System :: Monitoring
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
Requires-Dist: httpx
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest; extra == "dev"
|
|
22
|
+
Requires-Dist: build; extra == "dev"
|
|
23
|
+
Requires-Dist: twine; extra == "dev"
|
|
24
|
+
Provides-Extra: agents
|
|
25
|
+
Requires-Dist: openai-agents>=0.17; extra == "agents"
|
|
26
|
+
Provides-Extra: live
|
|
27
|
+
Requires-Dist: openai-agents>=0.17; extra == "live"
|
|
28
|
+
Requires-Dist: openai>=1.0; extra == "live"
|
|
29
|
+
|
|
30
|
+
# Open Token Python SDK
|
|
31
|
+
|
|
32
|
+
## Using `UsageReporter` directly
|
|
33
|
+
|
|
34
|
+
If you want to report usage from any provider, use `UsageReporter` directly:
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from opentoken import UsageReporter
|
|
38
|
+
|
|
39
|
+
reporter = UsageReporter(
|
|
40
|
+
open_token_api_key=os.environ["OPENTOKEN_API_KEY"],
|
|
41
|
+
tags={"service": "checkout-api", "env": "prod"},
|
|
42
|
+
batch=True, # default; set False to POST one event at a time
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
response = your_provider_client.chat.completions.create(...)
|
|
46
|
+
reporter.report_usage(response=response, latency_ms=..., tags={"workflow_id": "..."})
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`report_usage(...)` enqueues the report on a bounded in-memory queue and
|
|
50
|
+
returns immediately (~microseconds on the caller's thread). A background
|
|
51
|
+
daemon thread drains the queue and POSTs to `/v1/usage:batch`. You don't
|
|
52
|
+
manage the thread — it's started lazily on your first call and dies with
|
|
53
|
+
the process.
|
|
54
|
+
|
|
55
|
+
## OpenAI Agents SDK integration
|
|
56
|
+
|
|
57
|
+
If you're using the OpenAI Agents SDK, register the tracing bridge once at
|
|
58
|
+
startup and every `Runner.run(...)` will forward its LLM spans to
|
|
59
|
+
OpenToken. You never call `report_usage` yourself.
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from opentoken import UsageReporter, with_tags
|
|
63
|
+
from opentoken.integrations.agents import register_trace
|
|
64
|
+
from agents import Agent, Runner
|
|
65
|
+
|
|
66
|
+
# One-time setup — static tags apply to every event this reporter sends.
|
|
67
|
+
reporter = UsageReporter(open_token_api_key=..., tags={"env": "prod"})
|
|
68
|
+
register_trace(reporter)
|
|
69
|
+
|
|
70
|
+
sales_agent = Agent(name="sales_agent", model="gpt-5.5", instructions="...")
|
|
71
|
+
|
|
72
|
+
# Per-request handler — dynamic tags scoped to this one call.
|
|
73
|
+
async def handle(customer_id, question):
|
|
74
|
+
with with_tags(customer_id=customer_id, agent_name="sales"):
|
|
75
|
+
return await Runner.run(sales_agent, question)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Every event that flows out of that `Runner.run` lands with the merged tag
|
|
79
|
+
set — reporter defaults + whatever's active in the surrounding `with_tags`
|
|
80
|
+
block.
|
|
81
|
+
|
|
82
|
+
### `with_tags(**tags)` — dynamic per-request tags
|
|
83
|
+
|
|
84
|
+
`with_tags` is the customer-facing knob for tags that vary per request:
|
|
85
|
+
`customer_id`, `session_id`, `workflow`, whatever you want. It's a
|
|
86
|
+
context manager backed by `contextvars`, so:
|
|
87
|
+
|
|
88
|
+
- **Async-safe.** Concurrent tasks each maintain their own tag scope —
|
|
89
|
+
no cross-request bleed.
|
|
90
|
+
- **Nested.** Inner blocks inherit outer tags and override on collisions.
|
|
91
|
+
- **Works with both flows.** Whether the event is emitted by the Agents
|
|
92
|
+
integration or by a manual `reporter.report_usage(...)` call, the same
|
|
93
|
+
ambient stack is read.
|
|
94
|
+
|
|
95
|
+
### Tag precedence (highest wins)
|
|
96
|
+
|
|
97
|
+
For any event, tags merge in this order:
|
|
98
|
+
|
|
99
|
+
1. `UsageReporter(tags=...)` — reporter defaults, static, process-wide
|
|
100
|
+
2. `with_tags(...)` — ambient, request-scoped
|
|
101
|
+
3. `report_usage(tags=...)` — per-call, most specific
|
|
102
|
+
|
|
103
|
+
Later layers override earlier ones on key collisions.
|
|
104
|
+
|
|
105
|
+
### What lands in `transactions.tags`
|
|
106
|
+
|
|
107
|
+
Given the example above, one event would look like:
|
|
108
|
+
|
|
109
|
+
```json
|
|
110
|
+
{
|
|
111
|
+
"env": "prod", // reporter default
|
|
112
|
+
"customer_id": "cus_123", // from with_tags
|
|
113
|
+
"agent_name": "sales" // from with_tags
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
No auto-injected tags. What you write is what lands.
|
|
118
|
+
|
|
119
|
+
## Failure modes
|
|
120
|
+
|
|
121
|
+
- **HTTP failure (5xx, timeout, network blip).** The batch is retried up
|
|
122
|
+
to 3 times with exponential backoff + jitter, then dropped and logged.
|
|
123
|
+
The worker keeps running.
|
|
124
|
+
- **Unexpected exception in the worker.** The worker's main loop catches
|
|
125
|
+
any exception, logs it, and continues. If the thread does die despite
|
|
126
|
+
that, the next `report_usage()` call starts a fresh one; only the event
|
|
127
|
+
in-flight at the time of the crash is lost — queued events are
|
|
128
|
+
preserved.
|
|
129
|
+
- **Process death (SIGKILL, crash, sudden exit).** The in-memory queue is
|
|
130
|
+
lost. Long-lived servers are fine; short scripts should call
|
|
131
|
+
`reporter.flush()` before exit, or rely on the built-in `atexit` flush
|
|
132
|
+
(bounded to 5 seconds so slow OpenToken never hangs your process).
|
|
133
|
+
|
|
134
|
+
Enable `logging.getLogger("opentoken")` to see retries, drops, and worker
|
|
135
|
+
restarts. Call `reporter.stats()` any time to inspect `sent`, `dropped`,
|
|
136
|
+
`retried`, `batches_flushed`, `worker_restarts`, `queue_depth`.
|
|
137
|
+
|
|
138
|
+
## Response shape
|
|
139
|
+
|
|
140
|
+
Both `/v1/usage` and `/v1/usage:batch` return the same positional bool
|
|
141
|
+
array — one element per input event:
|
|
142
|
+
|
|
143
|
+
```json
|
|
144
|
+
{"results": [true, false, true]}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Each `true` means the event was accepted; each `false` means it failed
|
|
148
|
+
(unknown model, missing usage block, etc.). The SDK counts `true` events
|
|
149
|
+
toward `sent` and retries `false` events up to `max_retries` times, then
|
|
150
|
+
counts still-failing events toward `dropped`.
|
|
151
|
+
|
|
152
|
+
HTTP status is `200` for any well-formed request — individual event
|
|
153
|
+
failures are surfaced inside `results`, not at the HTTP layer. The
|
|
154
|
+
endpoints return `4xx` only for whole-request problems (auth, malformed
|
|
155
|
+
JSON, oversized batch).
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/opentoken/__init__.py
|
|
4
|
+
src/opentoken/constants.py
|
|
5
|
+
src/opentoken/reporter.py
|
|
6
|
+
src/opentoken/schemas.py
|
|
7
|
+
src/opentoken/integrations/__init__.py
|
|
8
|
+
src/opentoken/integrations/agents.py
|
|
9
|
+
src/opentoken_sdk.egg-info/PKG-INFO
|
|
10
|
+
src/opentoken_sdk.egg-info/SOURCES.txt
|
|
11
|
+
src/opentoken_sdk.egg-info/dependency_links.txt
|
|
12
|
+
src/opentoken_sdk.egg-info/requires.txt
|
|
13
|
+
src/opentoken_sdk.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
opentoken
|