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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ """OpenToken Python SDK."""
2
+
3
+ from opentoken.reporter import UsageReporter, with_tags
4
+
5
+ __all__ = ["UsageReporter", "with_tags"]
@@ -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,13 @@
1
+ httpx
2
+
3
+ [agents]
4
+ openai-agents>=0.17
5
+
6
+ [dev]
7
+ pytest
8
+ build
9
+ twine
10
+
11
+ [live]
12
+ openai-agents>=0.17
13
+ openai>=1.0