metergraph 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,88 @@
1
+ Metadata-Version: 2.4
2
+ Name: metergraph
3
+ Version: 0.1.0
4
+ Summary: Fire-and-forget LLM spend capture for Metergraph
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Provides-Extra: dev
9
+ Requires-Dist: pytest>=8; extra == "dev"
10
+
11
+ # metergraph (Python)
12
+
13
+ Zero-runtime-dependency capture for OpenAI, Anthropic, and Gemini clients.
14
+ `wrap()` initializes capture from the environment, so setup is one line per
15
+ client; call `metergraph.init(...)` before the first `wrap()` only to pass
16
+ options in code.
17
+
18
+ ```python
19
+ import metergraph
20
+ from openai import OpenAI
21
+
22
+ # Anthropic() and google-genai's genai.Client() wrap the same way.
23
+ client = metergraph.wrap(OpenAI())
24
+ metergraph.set_session("ticket-123")
25
+
26
+ with metergraph.route("ticket-classifier", unit="answer", capture_text=True):
27
+ model = metergraph.model_for("ticket-classifier", default="gpt-4.1-mini")
28
+ client.chat.completions.create(model=model, messages=[...])
29
+
30
+ # Emit this after the user-visible task resolves. It shares the bounded async
31
+ # transport and contains no prompt or output content.
32
+ metergraph.record_outcome(
33
+ "ticket-classifier",
34
+ model=model,
35
+ task_completed=True,
36
+ feedback_score=1,
37
+ turns_to_resolution=2,
38
+ escalated=False,
39
+ )
40
+ ```
41
+
42
+ Configuration:
43
+
44
+ - `METERGRAPH_APP_TOKEN` — required bearer token
45
+ - `METERGRAPH_INGEST_URL` — optional override; defaults to the hosted HTTPS endpoint
46
+ - `METERGRAPH_CAPTURE_TEXT=1` — opt in to content capture globally; default is metadata-only
47
+ - `METERGRAPH_DISABLED=1` — process kill switch
48
+ - `METERGRAPH_QUEUE_SIZE`, `METERGRAPH_BATCH_SIZE`, `METERGRAPH_FLUSH_SECONDS`
49
+
50
+ Delivery is bounded and off the request path. Queue overflow or a collector
51
+ outage drops capture and increments internal counters; it never changes the
52
+ provider call. Each wire batch is bounded to 512 KiB after optional gzip.
53
+ `capture_text=True` or `False` on `route()` overrides the global content policy
54
+ for that route. Config reads are ETag-aware and fail open to the default model.
55
+ `record_outcome` requires a stable session ID and the model actually used so a
56
+ session-sticky canary can compare task completion and optional feedback,
57
+ turn-count, escalation, abandonment, edit-distance, and regeneration signals.
58
+
59
+ OpenAI Batch API output JSONL is captured per inference when a wrapped
60
+ `client.files.content()` / `retrieve_content()` result is read. Anthropic
61
+ message batches are captured per inference while iterating a wrapped
62
+ `client.messages.batches.results()` result. Run result consumption inside a
63
+ `route()` context so the asynchronous batch retains its product route. Batch
64
+ rows carry real per-result usage and the batch pricing flag; job-management
65
+ polls themselves are not miscounted as model calls.
66
+
67
+ ## Set up with an AI coding agent
68
+
69
+ Paste this into Claude Code, Codex, Cursor, or any coding agent inside the
70
+ codebase you want instrumented:
71
+
72
+ ```text
73
+ Instrument this codebase's LLM API costs with the `metergraph` PyPI package
74
+ (https://github.com/PioneerSquareLabs/metergraphsdk): pip install metergraph,
75
+ then wrap every OpenAI()/AsyncOpenAI(), Anthropic()/AsyncAnthropic(), and
76
+ genai.Client() construction in place, e.g. client = metergraph.wrap(OpenAI()).
77
+ wrap() returns the same client and initializes itself from the environment:
78
+ METERGRAPH_APP_TOKEN is required (capture is silently off without it) and
79
+ METERGRAPH_INGEST_URL is only for self-hosted servers. Add both to
80
+ .env.example, and never commit a real token. Capture is metadata-only (tokens,
81
+ latency, model, no prompt/completion content) and fail-open, so do not change
82
+ call sites, arguments, or error handling; sync, async, and streaming work
83
+ unchanged. Attribution to the calling function is automatic; optionally pin
84
+ stable names on key LLM-calling functions with @metergraph.track. On
85
+ serverless, call metergraph.flush() before the handler returns. When done,
86
+ list every client you wrapped and flag LLM calls made outside the official
87
+ openai / anthropic / google-genai SDKs, since those are not captured.
88
+ ```
@@ -0,0 +1,78 @@
1
+ # metergraph (Python)
2
+
3
+ Zero-runtime-dependency capture for OpenAI, Anthropic, and Gemini clients.
4
+ `wrap()` initializes capture from the environment, so setup is one line per
5
+ client; call `metergraph.init(...)` before the first `wrap()` only to pass
6
+ options in code.
7
+
8
+ ```python
9
+ import metergraph
10
+ from openai import OpenAI
11
+
12
+ # Anthropic() and google-genai's genai.Client() wrap the same way.
13
+ client = metergraph.wrap(OpenAI())
14
+ metergraph.set_session("ticket-123")
15
+
16
+ with metergraph.route("ticket-classifier", unit="answer", capture_text=True):
17
+ model = metergraph.model_for("ticket-classifier", default="gpt-4.1-mini")
18
+ client.chat.completions.create(model=model, messages=[...])
19
+
20
+ # Emit this after the user-visible task resolves. It shares the bounded async
21
+ # transport and contains no prompt or output content.
22
+ metergraph.record_outcome(
23
+ "ticket-classifier",
24
+ model=model,
25
+ task_completed=True,
26
+ feedback_score=1,
27
+ turns_to_resolution=2,
28
+ escalated=False,
29
+ )
30
+ ```
31
+
32
+ Configuration:
33
+
34
+ - `METERGRAPH_APP_TOKEN` — required bearer token
35
+ - `METERGRAPH_INGEST_URL` — optional override; defaults to the hosted HTTPS endpoint
36
+ - `METERGRAPH_CAPTURE_TEXT=1` — opt in to content capture globally; default is metadata-only
37
+ - `METERGRAPH_DISABLED=1` — process kill switch
38
+ - `METERGRAPH_QUEUE_SIZE`, `METERGRAPH_BATCH_SIZE`, `METERGRAPH_FLUSH_SECONDS`
39
+
40
+ Delivery is bounded and off the request path. Queue overflow or a collector
41
+ outage drops capture and increments internal counters; it never changes the
42
+ provider call. Each wire batch is bounded to 512 KiB after optional gzip.
43
+ `capture_text=True` or `False` on `route()` overrides the global content policy
44
+ for that route. Config reads are ETag-aware and fail open to the default model.
45
+ `record_outcome` requires a stable session ID and the model actually used so a
46
+ session-sticky canary can compare task completion and optional feedback,
47
+ turn-count, escalation, abandonment, edit-distance, and regeneration signals.
48
+
49
+ OpenAI Batch API output JSONL is captured per inference when a wrapped
50
+ `client.files.content()` / `retrieve_content()` result is read. Anthropic
51
+ message batches are captured per inference while iterating a wrapped
52
+ `client.messages.batches.results()` result. Run result consumption inside a
53
+ `route()` context so the asynchronous batch retains its product route. Batch
54
+ rows carry real per-result usage and the batch pricing flag; job-management
55
+ polls themselves are not miscounted as model calls.
56
+
57
+ ## Set up with an AI coding agent
58
+
59
+ Paste this into Claude Code, Codex, Cursor, or any coding agent inside the
60
+ codebase you want instrumented:
61
+
62
+ ```text
63
+ Instrument this codebase's LLM API costs with the `metergraph` PyPI package
64
+ (https://github.com/PioneerSquareLabs/metergraphsdk): pip install metergraph,
65
+ then wrap every OpenAI()/AsyncOpenAI(), Anthropic()/AsyncAnthropic(), and
66
+ genai.Client() construction in place, e.g. client = metergraph.wrap(OpenAI()).
67
+ wrap() returns the same client and initializes itself from the environment:
68
+ METERGRAPH_APP_TOKEN is required (capture is silently off without it) and
69
+ METERGRAPH_INGEST_URL is only for self-hosted servers. Add both to
70
+ .env.example, and never commit a real token. Capture is metadata-only (tokens,
71
+ latency, model, no prompt/completion content) and fail-open, so do not change
72
+ call sites, arguments, or error handling; sync, async, and streaming work
73
+ unchanged. Attribution to the calling function is automatic; optionally pin
74
+ stable names on key LLM-calling functions with @metergraph.track. On
75
+ serverless, call metergraph.flush() before the handler returns. When done,
76
+ list every client you wrapped and flag LLM calls made outside the official
77
+ openai / anthropic / google-genai SDKs, since those are not captured.
78
+ ```
@@ -0,0 +1,21 @@
1
+ [project]
2
+ name = "metergraph"
3
+ version = "0.1.0"
4
+ description = "Fire-and-forget LLM spend capture for Metergraph"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = "Apache-2.0"
8
+ dependencies = []
9
+
10
+ [project.optional-dependencies]
11
+ dev = ["pytest>=8"]
12
+
13
+ [build-system]
14
+ requires = ["setuptools>=68"]
15
+ build-backend = "setuptools.build_meta"
16
+
17
+ [tool.setuptools.packages.find]
18
+ where = ["src"]
19
+
20
+ [tool.pytest.ini_options]
21
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,219 @@
1
+ """Public Metergraph Python SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import atexit
6
+ import logging
7
+ import os
8
+ import uuid
9
+ from datetime import datetime, timezone
10
+ from typing import Any, Callable
11
+
12
+ from ._capture import Options, Runtime, set_runtime
13
+ from ._capture import wrap as _wrap
14
+ from ._config import ConfigPoller
15
+ from ._context import route, set_session, set_tags, snapshot, wrap_executor
16
+ from ._track import track
17
+ from ._transport import Writer
18
+ from ._version import SDK_VERSION
19
+
20
+
21
+ __version__ = SDK_VERSION
22
+ DEFAULT_INGEST_URL = "https://d2xus7mp8zdv6t.cloudfront.net"
23
+ log = logging.getLogger("metergraph")
24
+ _writer: Writer | None = None
25
+ _config: ConfigPoller | None = None
26
+ _initialized = False
27
+ _warned_no_token = False
28
+
29
+
30
+ def _env_bool(name: str, default: bool) -> bool:
31
+ value = os.getenv(name)
32
+ if value is None:
33
+ return default
34
+ return value.lower() not in {"0", "false", "no", "off"}
35
+
36
+
37
+ def init(
38
+ *,
39
+ token: str | None = None,
40
+ ingest_url: str | None = None,
41
+ capture_text: bool | None = None,
42
+ redact: Callable[[str, str], str] | None = None,
43
+ app_root: str | None = None,
44
+ skip_frames: list[str] | None = None,
45
+ environment: str | None = None,
46
+ disabled: bool | None = None,
47
+ ) -> None:
48
+ """Initialize capture. This function is idempotent and never raises."""
49
+ global _initialized, _warned_no_token, _writer, _config
50
+ if _initialized:
51
+ return
52
+ if os.getenv("METERGRAPH_DISABLED") == "1" or disabled:
53
+ _initialized = True
54
+ return
55
+ token = token or os.getenv("METERGRAPH_APP_TOKEN")
56
+ ingest_url = ingest_url or os.getenv("METERGRAPH_INGEST_URL") or DEFAULT_INGEST_URL
57
+ if not token or not ingest_url:
58
+ # Stay uninitialized so a later init() that supplies a token succeeds.
59
+ if not _warned_no_token:
60
+ _warned_no_token = True
61
+ log.warning(
62
+ "Metergraph capture disabled: token and ingest URL are required"
63
+ )
64
+ return
65
+ _initialized = True
66
+ try:
67
+ _writer = Writer(
68
+ token,
69
+ ingest_url,
70
+ queue_size=int(os.getenv("METERGRAPH_QUEUE_SIZE", "2000")),
71
+ batch_size=int(os.getenv("METERGRAPH_BATCH_SIZE", "100")),
72
+ flush_seconds=float(os.getenv("METERGRAPH_FLUSH_SECONDS", "5")),
73
+ )
74
+ options = Options(
75
+ capture_text=(
76
+ _env_bool("METERGRAPH_CAPTURE_TEXT", False)
77
+ if capture_text is None
78
+ else capture_text
79
+ ),
80
+ redact=redact,
81
+ app_root=os.path.realpath(app_root or os.getcwd()),
82
+ skip_frames=tuple(skip_frames or ()),
83
+ environment=environment or os.getenv("METERGRAPH_ENV"),
84
+ text_max_bytes=int(os.getenv("METERGRAPH_TEXT_MAX_BYTES", "100000")),
85
+ )
86
+ set_runtime(Runtime(_writer, options))
87
+ _config = ConfigPoller(
88
+ token,
89
+ ingest_url,
90
+ poll_seconds=float(os.getenv("METERGRAPH_CONFIG_POLL_SECONDS", "30")),
91
+ hard_ttl_seconds=float(
92
+ os.getenv("METERGRAPH_CONFIG_HARD_TTL_SECONDS", "120")
93
+ ),
94
+ )
95
+ atexit.register(shutdown)
96
+ except Exception:
97
+ set_runtime(None)
98
+ if _writer:
99
+ _writer.shutdown()
100
+ _writer = None
101
+ _config = None
102
+ log.warning(
103
+ "Metergraph initialization failed; application is running uninstrumented"
104
+ )
105
+
106
+
107
+ def wrap(client: Any, *, provider: str | None = None) -> Any:
108
+ """Wrap an OpenAI, Anthropic, or Google client for capture.
109
+
110
+ Calls init() automatically, so with env-var configuration this is the
111
+ only setup line needed. Call init(...) first to pass options in code.
112
+ """
113
+ init()
114
+ return _wrap(client, provider=provider)
115
+
116
+
117
+ def model_for(route_name: str, *, default: str, session_key: str | None = None) -> str:
118
+ """Return a sticky canary model, or the incumbent on every failure path."""
119
+ if _config is None:
120
+ return default
121
+ return _config.model_for(route_name, default, session_key or snapshot().session_id)
122
+
123
+
124
+ def record_outcome(
125
+ route_name: str,
126
+ *,
127
+ model: str,
128
+ task_completed: bool,
129
+ session_key: str | None = None,
130
+ feedback_score: float | None = None,
131
+ turns_to_resolution: int | None = None,
132
+ escalated: bool | None = None,
133
+ abandoned: bool | None = None,
134
+ edit_distance_ratio: float | None = None,
135
+ regeneration_count: int | None = None,
136
+ event_id: str | None = None,
137
+ ) -> bool:
138
+ """Enqueue a content-free real outcome without touching the request path."""
139
+ if _writer is None or not isinstance(task_completed, bool):
140
+ return False
141
+ route_name = str(route_name).strip()[:512]
142
+ model = str(model).strip()[:512]
143
+ session_key = str(session_key or snapshot().session_id or "").strip()[:512]
144
+ event_id = str(event_id or uuid.uuid4()).strip()[:128]
145
+ try:
146
+ feedback_score = float(feedback_score) if feedback_score is not None else None
147
+ turns_to_resolution = (
148
+ int(turns_to_resolution) if turns_to_resolution is not None else None
149
+ )
150
+ edit_distance_ratio = (
151
+ float(edit_distance_ratio) if edit_distance_ratio is not None else None
152
+ )
153
+ regeneration_count = (
154
+ int(regeneration_count) if regeneration_count is not None else None
155
+ )
156
+ except (TypeError, ValueError, OverflowError):
157
+ return False
158
+ if not route_name or not model or not session_key or not event_id:
159
+ return False
160
+ if feedback_score is not None and not -1 <= feedback_score <= 1:
161
+ return False
162
+ if turns_to_resolution is not None and not 1 <= turns_to_resolution <= 1_000_000:
163
+ return False
164
+ if edit_distance_ratio is not None and not 0 <= edit_distance_ratio <= 1:
165
+ return False
166
+ if regeneration_count is not None and not 0 <= regeneration_count <= 1_000_000:
167
+ return False
168
+ if escalated is not None and not isinstance(escalated, bool):
169
+ return False
170
+ if abandoned is not None and not isinstance(abandoned, bool):
171
+ return False
172
+ return _writer.enqueue(
173
+ {
174
+ "event_type": "outcome",
175
+ "event_id": event_id,
176
+ "ts": datetime.now(timezone.utc).isoformat(),
177
+ "route": route_name,
178
+ "session_id": session_key,
179
+ "model": model,
180
+ "task_completed": task_completed,
181
+ "feedback_score": feedback_score,
182
+ "turns_to_resolution": turns_to_resolution,
183
+ "escalated": escalated,
184
+ "abandoned": abandoned,
185
+ "edit_distance_ratio": edit_distance_ratio,
186
+ "regeneration_count": regeneration_count,
187
+ }
188
+ )
189
+
190
+
191
+ def flush(timeout: float = 3.0) -> bool:
192
+ return True if _writer is None else _writer.flush(timeout)
193
+
194
+
195
+ def shutdown() -> None:
196
+ global _writer, _config
197
+ if _config:
198
+ _config.stop()
199
+ _config = None
200
+ if _writer:
201
+ _writer.shutdown()
202
+ _writer = None
203
+ set_runtime(None)
204
+
205
+
206
+ __all__ = [
207
+ "DEFAULT_INGEST_URL",
208
+ "flush",
209
+ "init",
210
+ "model_for",
211
+ "record_outcome",
212
+ "route",
213
+ "set_session",
214
+ "set_tags",
215
+ "shutdown",
216
+ "track",
217
+ "wrap",
218
+ "wrap_executor",
219
+ ]