metergraph 0.2.1__tar.gz → 0.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. {metergraph-0.2.1 → metergraph-0.3.2}/PKG-INFO +63 -14
  2. metergraph-0.3.2/README.md +127 -0
  3. {metergraph-0.2.1 → metergraph-0.3.2}/pyproject.toml +2 -2
  4. {metergraph-0.2.1 → metergraph-0.3.2}/src/metergraph/__init__.py +37 -15
  5. {metergraph-0.2.1 → metergraph-0.3.2}/src/metergraph/_capture.py +310 -31
  6. {metergraph-0.2.1 → metergraph-0.3.2}/src/metergraph/_context.py +89 -0
  7. {metergraph-0.2.1 → metergraph-0.3.2}/src/metergraph/_template.py +18 -2
  8. {metergraph-0.2.1 → metergraph-0.3.2}/src/metergraph/_version.py +1 -1
  9. {metergraph-0.2.1 → metergraph-0.3.2}/src/metergraph.egg-info/PKG-INFO +63 -14
  10. {metergraph-0.2.1 → metergraph-0.3.2}/src/metergraph.egg-info/SOURCES.txt +1 -0
  11. metergraph-0.3.2/tests/test_edge_cases.py +556 -0
  12. {metergraph-0.2.1 → metergraph-0.3.2}/tests/test_real_client_integration.py +101 -0
  13. {metergraph-0.2.1 → metergraph-0.3.2}/tests/test_sdk.py +326 -14
  14. metergraph-0.2.1/README.md +0 -78
  15. {metergraph-0.2.1 → metergraph-0.3.2}/setup.cfg +0 -0
  16. {metergraph-0.2.1 → metergraph-0.3.2}/src/metergraph/_config.py +0 -0
  17. {metergraph-0.2.1 → metergraph-0.3.2}/src/metergraph/_failure_log.py +0 -0
  18. {metergraph-0.2.1 → metergraph-0.3.2}/src/metergraph/_track.py +0 -0
  19. {metergraph-0.2.1 → metergraph-0.3.2}/src/metergraph/_transport.py +0 -0
  20. {metergraph-0.2.1 → metergraph-0.3.2}/src/metergraph.egg-info/dependency_links.txt +0 -0
  21. {metergraph-0.2.1 → metergraph-0.3.2}/src/metergraph.egg-info/requires.txt +0 -0
  22. {metergraph-0.2.1 → metergraph-0.3.2}/src/metergraph.egg-info/top_level.txt +0 -0
  23. {metergraph-0.2.1 → metergraph-0.3.2}/tests/test_seam_reality.py +0 -0
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: metergraph
3
- Version: 0.2.1
3
+ Version: 0.3.2
4
4
  Summary: Fire-and-forget LLM spend capture for Metergraph
5
5
  Author: Pioneer Square Labs
6
6
  License-Expression: Apache-2.0
7
7
  Project-URL: Homepage, https://www.metergraph.dev/
8
8
  Project-URL: Repository, https://github.com/PioneerSquareLabs/metergraphsdk
9
9
  Project-URL: Issues, https://github.com/PioneerSquareLabs/metergraphsdk/issues
10
- Keywords: llm,observability,openai,anthropic,gemini,cost-tracking
10
+ Keywords: llm,observability,openai,anthropic,gemini,vercel-ai-gateway,cost-tracking
11
11
  Classifier: Intended Audience :: Developers
12
12
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
13
13
  Classifier: Typing :: Typed
@@ -21,7 +21,8 @@ Requires-Dist: google-genai>=1; extra == "dev"
21
21
 
22
22
  # metergraph (Python)
23
23
 
24
- Zero-runtime-dependency capture for OpenAI, Anthropic, and Gemini clients.
24
+ Zero-runtime-dependency capture for OpenAI, Anthropic, Gemini, and Python
25
+ Vercel AI Gateway clients.
25
26
  `wrap()` initializes capture from the environment, so setup is one line per
26
27
  client; call `metergraph.init(...)` before the first `wrap()` only to pass
27
28
  options in code.
@@ -34,9 +35,10 @@ from openai import OpenAI
34
35
  client = metergraph.wrap(OpenAI())
35
36
  metergraph.set_session("ticket-123")
36
37
 
37
- with metergraph.route("ticket-classifier", unit="answer", capture_text=True):
38
- model = metergraph.model_for("ticket-classifier", default="gpt-4.1-mini")
39
- client.chat.completions.create(model=model, messages=[...])
38
+ with metergraph.trace("ticket-workflow"):
39
+ with metergraph.route("ticket-classifier", unit="answer"):
40
+ model = metergraph.model_for("ticket-classifier", default="gpt-4.1-mini")
41
+ client.chat.completions.create(model=model, messages=[...])
40
42
 
41
43
  # Emit this after the user-visible task resolves. It shares the bounded async
42
44
  # transport and contains no prompt or output content.
@@ -50,19 +52,59 @@ metergraph.record_outcome(
50
52
  )
51
53
  ```
52
54
 
55
+ Vercel's supported Python surface is AI Gateway through the OpenAI or
56
+ Anthropic SDK. Point either client at the public gateway and `wrap()` detects
57
+ it automatically:
58
+
59
+ ```python
60
+ import os
61
+ import metergraph
62
+ from openai import OpenAI
63
+
64
+ gateway = metergraph.wrap(OpenAI(
65
+ api_key=os.getenv("AI_GATEWAY_API_KEY") or os.getenv("VERCEL_OIDC_TOKEN"),
66
+ base_url="https://ai-gateway.vercel.sh/v1",
67
+ ))
68
+
69
+ gateway.chat.completions.create(
70
+ model="anthropic/claude-sonnet-4.6",
71
+ messages=[{"role": "user", "content": "Hello"}],
72
+ )
73
+ ```
74
+
75
+ Creator-qualified model IDs are normalized for gateway catalog pricing. Sync,
76
+ async, streaming, tool calls, and OpenAI Responses API calls are captured. Use
77
+ `metergraph.wrap(client, provider="vercel")` only when a compatible client is
78
+ behind a custom gateway URL that cannot be detected automatically.
79
+
53
80
  Configuration:
54
81
 
55
82
  - `METERGRAPH_APP_TOKEN` — required bearer token
56
83
  - `METERGRAPH_INGEST_URL` — optional override; defaults to the hosted HTTPS endpoint
57
- - `METERGRAPH_CAPTURE_TEXT=1` — opt in to content capture globally; default is metadata-only
84
+ - `METERGRAPH_CAPTURE_TEXT=0` — opt out of content capture globally
58
85
  - `METERGRAPH_DISABLED=1` — process kill switch
59
86
  - `METERGRAPH_QUEUE_SIZE`, `METERGRAPH_BATCH_SIZE`, `METERGRAPH_FLUSH_SECONDS`
60
87
 
61
88
  Delivery is bounded and off the request path. Queue overflow or a collector
62
89
  outage drops capture and increments internal counters; it never changes the
63
90
  provider call. Each wire batch is bounded to 512 KiB after optional gzip.
64
- `capture_text=True` or `False` on `route()` overrides the global content policy
65
- for that route. Config reads are ETag-aware and fail open to the default model.
91
+ SDK 0.3 captures the scrubbed provider request and a normalized response
92
+ envelope, including assistant content and tool calls, by default. Provider
93
+ credentials and transport headers are removed. Request and response are each
94
+ limited to 100 KiB of UTF-8 with an explicit truncation marker.
95
+ `capture_text=False` on `route()` or `trace()` overrides the global content
96
+ policy for a sensitive operation. The equivalent initialization option is
97
+ `metergraph.init(capture_text=False)`. The public open-source server continues
98
+ to discard content even when the SDK sends it; the hosted dashboard retains
99
+ content under the workspace retention period.
100
+
101
+ `metergraph.trace(name, trace_id=..., parent_span_id=...)` is a sync/async
102
+ context manager and decorator. Calls inside one trace share a trace ID and
103
+ receive distinct span IDs. Calls outside a trace become deterministic
104
+ single-span traces after ingestion. Manual IDs can join work across process
105
+ boundaries; automatic W3C HTTP propagation is not included.
106
+
107
+ Config reads are ETag-aware and fail open to the default model.
66
108
  `record_outcome` requires a stable session ID and the model actually used so a
67
109
  session-sticky canary can compare task completion and optional feedback,
68
110
  turn-count, escalation, abandonment, edit-distance, and regeneration signals.
@@ -85,14 +127,21 @@ Instrument this codebase's LLM API costs with the `metergraph` PyPI package
85
127
  (https://github.com/PioneerSquareLabs/metergraphsdk): pip install metergraph,
86
128
  then wrap every OpenAI()/AsyncOpenAI(), Anthropic()/AsyncAnthropic(), and
87
129
  genai.Client() construction in place, e.g. client = metergraph.wrap(OpenAI()).
130
+ OpenAI or Anthropic clients pointed at https://ai-gateway.vercel.sh are Vercel
131
+ AI Gateway clients and are detected automatically; keep their creator/model ID
132
+ and AI_GATEWAY_API_KEY / VERCEL_OIDC_TOKEN configuration unchanged.
88
133
  wrap() returns the same client and initializes itself from the environment:
89
134
  METERGRAPH_APP_TOKEN is required (capture is silently off without it) and
90
135
  METERGRAPH_INGEST_URL is only for self-hosted servers. Add both to
91
- .env.example, and never commit a real token. Capture is metadata-only (tokens,
92
- latency, model, no prompt/completion content) and fail-open, so do not change
93
- call sites, arguments, or error handling; sync, async, and streaming work
94
- unchanged. Attribution to the calling function is automatic; optionally pin
95
- stable names on key LLM-calling functions with @metergraph.track. On
136
+ .env.example, and never commit a real token. SDK 0.3 captures scrubbed provider
137
+ requests and normalized responses by default for the hosted dashboard; use
138
+ METERGRAPH_CAPTURE_TEXT=0 or capture_text=False around sensitive operations.
139
+ Provider credentials and transport headers must never be captured. Capture is
140
+ fail-open, so do not change call sites, arguments, or error handling; sync,
141
+ async, and streaming work unchanged. Use metergraph.trace("stable-name") to
142
+ group multi-call workflows. Attribution to the calling function is automatic;
143
+ optionally pin stable names on key LLM-calling functions with
144
+ @metergraph.track. On
96
145
  serverless, call metergraph.flush() before the handler returns. When done,
97
146
  list every client you wrapped and flag LLM calls made outside the official
98
147
  openai / anthropic / google-genai SDKs, since those are not captured.
@@ -0,0 +1,127 @@
1
+ # metergraph (Python)
2
+
3
+ Zero-runtime-dependency capture for OpenAI, Anthropic, Gemini, and Python
4
+ Vercel AI Gateway clients.
5
+ `wrap()` initializes capture from the environment, so setup is one line per
6
+ client; call `metergraph.init(...)` before the first `wrap()` only to pass
7
+ options in code.
8
+
9
+ ```python
10
+ import metergraph
11
+ from openai import OpenAI
12
+
13
+ # Anthropic() and google-genai's genai.Client() wrap the same way.
14
+ client = metergraph.wrap(OpenAI())
15
+ metergraph.set_session("ticket-123")
16
+
17
+ with metergraph.trace("ticket-workflow"):
18
+ with metergraph.route("ticket-classifier", unit="answer"):
19
+ model = metergraph.model_for("ticket-classifier", default="gpt-4.1-mini")
20
+ client.chat.completions.create(model=model, messages=[...])
21
+
22
+ # Emit this after the user-visible task resolves. It shares the bounded async
23
+ # transport and contains no prompt or output content.
24
+ metergraph.record_outcome(
25
+ "ticket-classifier",
26
+ model=model,
27
+ task_completed=True,
28
+ feedback_score=1,
29
+ turns_to_resolution=2,
30
+ escalated=False,
31
+ )
32
+ ```
33
+
34
+ Vercel's supported Python surface is AI Gateway through the OpenAI or
35
+ Anthropic SDK. Point either client at the public gateway and `wrap()` detects
36
+ it automatically:
37
+
38
+ ```python
39
+ import os
40
+ import metergraph
41
+ from openai import OpenAI
42
+
43
+ gateway = metergraph.wrap(OpenAI(
44
+ api_key=os.getenv("AI_GATEWAY_API_KEY") or os.getenv("VERCEL_OIDC_TOKEN"),
45
+ base_url="https://ai-gateway.vercel.sh/v1",
46
+ ))
47
+
48
+ gateway.chat.completions.create(
49
+ model="anthropic/claude-sonnet-4.6",
50
+ messages=[{"role": "user", "content": "Hello"}],
51
+ )
52
+ ```
53
+
54
+ Creator-qualified model IDs are normalized for gateway catalog pricing. Sync,
55
+ async, streaming, tool calls, and OpenAI Responses API calls are captured. Use
56
+ `metergraph.wrap(client, provider="vercel")` only when a compatible client is
57
+ behind a custom gateway URL that cannot be detected automatically.
58
+
59
+ Configuration:
60
+
61
+ - `METERGRAPH_APP_TOKEN` — required bearer token
62
+ - `METERGRAPH_INGEST_URL` — optional override; defaults to the hosted HTTPS endpoint
63
+ - `METERGRAPH_CAPTURE_TEXT=0` — opt out of content capture globally
64
+ - `METERGRAPH_DISABLED=1` — process kill switch
65
+ - `METERGRAPH_QUEUE_SIZE`, `METERGRAPH_BATCH_SIZE`, `METERGRAPH_FLUSH_SECONDS`
66
+
67
+ Delivery is bounded and off the request path. Queue overflow or a collector
68
+ outage drops capture and increments internal counters; it never changes the
69
+ provider call. Each wire batch is bounded to 512 KiB after optional gzip.
70
+ SDK 0.3 captures the scrubbed provider request and a normalized response
71
+ envelope, including assistant content and tool calls, by default. Provider
72
+ credentials and transport headers are removed. Request and response are each
73
+ limited to 100 KiB of UTF-8 with an explicit truncation marker.
74
+ `capture_text=False` on `route()` or `trace()` overrides the global content
75
+ policy for a sensitive operation. The equivalent initialization option is
76
+ `metergraph.init(capture_text=False)`. The public open-source server continues
77
+ to discard content even when the SDK sends it; the hosted dashboard retains
78
+ content under the workspace retention period.
79
+
80
+ `metergraph.trace(name, trace_id=..., parent_span_id=...)` is a sync/async
81
+ context manager and decorator. Calls inside one trace share a trace ID and
82
+ receive distinct span IDs. Calls outside a trace become deterministic
83
+ single-span traces after ingestion. Manual IDs can join work across process
84
+ boundaries; automatic W3C HTTP propagation is not included.
85
+
86
+ Config reads are ETag-aware and fail open to the default model.
87
+ `record_outcome` requires a stable session ID and the model actually used so a
88
+ session-sticky canary can compare task completion and optional feedback,
89
+ turn-count, escalation, abandonment, edit-distance, and regeneration signals.
90
+
91
+ OpenAI Batch API output JSONL is captured per inference when a wrapped
92
+ `client.files.content()` / `retrieve_content()` result is read. Anthropic
93
+ message batches are captured per inference while iterating a wrapped
94
+ `client.messages.batches.results()` result. Run result consumption inside a
95
+ `route()` context so the asynchronous batch retains its product route. Batch
96
+ rows carry real per-result usage and the batch pricing flag; job-management
97
+ polls themselves are not miscounted as model calls.
98
+
99
+ ## Set up with an AI coding agent
100
+
101
+ Paste this into Claude Code, Codex, Cursor, or any coding agent inside the
102
+ codebase you want instrumented:
103
+
104
+ ```text
105
+ Instrument this codebase's LLM API costs with the `metergraph` PyPI package
106
+ (https://github.com/PioneerSquareLabs/metergraphsdk): pip install metergraph,
107
+ then wrap every OpenAI()/AsyncOpenAI(), Anthropic()/AsyncAnthropic(), and
108
+ genai.Client() construction in place, e.g. client = metergraph.wrap(OpenAI()).
109
+ OpenAI or Anthropic clients pointed at https://ai-gateway.vercel.sh are Vercel
110
+ AI Gateway clients and are detected automatically; keep their creator/model ID
111
+ and AI_GATEWAY_API_KEY / VERCEL_OIDC_TOKEN configuration unchanged.
112
+ wrap() returns the same client and initializes itself from the environment:
113
+ METERGRAPH_APP_TOKEN is required (capture is silently off without it) and
114
+ METERGRAPH_INGEST_URL is only for self-hosted servers. Add both to
115
+ .env.example, and never commit a real token. SDK 0.3 captures scrubbed provider
116
+ requests and normalized responses by default for the hosted dashboard; use
117
+ METERGRAPH_CAPTURE_TEXT=0 or capture_text=False around sensitive operations.
118
+ Provider credentials and transport headers must never be captured. Capture is
119
+ fail-open, so do not change call sites, arguments, or error handling; sync,
120
+ async, and streaming work unchanged. Use metergraph.trace("stable-name") to
121
+ group multi-call workflows. Attribution to the calling function is automatic;
122
+ optionally pin stable names on key LLM-calling functions with
123
+ @metergraph.track. On
124
+ serverless, call metergraph.flush() before the handler returns. When done,
125
+ list every client you wrapped and flag LLM calls made outside the official
126
+ openai / anthropic / google-genai SDKs, since those are not captured.
127
+ ```
@@ -1,12 +1,12 @@
1
1
  [project]
2
2
  name = "metergraph"
3
- version = "0.2.1"
3
+ version = "0.3.2"
4
4
  description = "Fire-and-forget LLM spend capture for Metergraph"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
7
7
  license = "Apache-2.0"
8
8
  authors = [{ name = "Pioneer Square Labs" }]
9
- keywords = ["llm", "observability", "openai", "anthropic", "gemini", "cost-tracking"]
9
+ keywords = ["llm", "observability", "openai", "anthropic", "gemini", "vercel-ai-gateway", "cost-tracking"]
10
10
  classifiers = [
11
11
  "Intended Audience :: Developers",
12
12
  "Topic :: Software Development :: Libraries :: Python Modules",
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import atexit
6
6
  import logging
7
+ import math
7
8
  import os
8
9
  import uuid
9
10
  from datetime import datetime, timezone
@@ -12,7 +13,7 @@ from typing import Any, Callable
12
13
  from ._capture import Options, Runtime, set_runtime
13
14
  from ._capture import wrap as _wrap
14
15
  from ._config import ConfigPoller
15
- from ._context import route, set_session, set_tags, snapshot, wrap_executor
16
+ from ._context import route, set_session, set_tags, snapshot, trace, wrap_executor
16
17
  from ._track import track
17
18
  from ._transport import Writer
18
19
  from ._version import SDK_VERSION
@@ -73,7 +74,7 @@ def init(
73
74
  )
74
75
  options = Options(
75
76
  capture_text=(
76
- _env_bool("METERGRAPH_CAPTURE_TEXT", False)
77
+ _env_bool("METERGRAPH_CAPTURE_TEXT", True)
77
78
  if capture_text is None
78
79
  else capture_text
79
80
  ),
@@ -81,7 +82,17 @@ def init(
81
82
  app_root=os.path.realpath(app_root or os.getcwd()),
82
83
  skip_frames=tuple(skip_frames or ()),
83
84
  environment=environment or os.getenv("METERGRAPH_ENV"),
84
- text_max_bytes=int(os.getenv("METERGRAPH_TEXT_MAX_BYTES", "100000")),
85
+ text_max_bytes=min(
86
+ 100 * 1024,
87
+ max(
88
+ 1,
89
+ int(
90
+ os.getenv(
91
+ "METERGRAPH_TEXT_MAX_BYTES", str(100 * 1024)
92
+ )
93
+ ),
94
+ ),
95
+ ),
85
96
  )
86
97
  set_runtime(Runtime(_writer, options))
87
98
  _config = ConfigPoller(
@@ -105,10 +116,13 @@ def init(
105
116
 
106
117
 
107
118
  def wrap(client: Any, *, provider: str | None = None) -> Any:
108
- """Wrap an OpenAI, Anthropic, or Google client for capture.
119
+ """Wrap an OpenAI, Anthropic, Google, or Vercel AI Gateway client.
109
120
 
110
121
  Calls init() automatically, so with env-var configuration this is the
111
- only setup line needed. Call init(...) first to pass options in code.
122
+ only setup line needed. OpenAI and Anthropic clients using Vercel's public
123
+ AI Gateway URL are detected automatically; pass ``provider="vercel"`` to
124
+ force gateway handling for a compatible client with a custom URL. Call
125
+ init(...) first to pass Metergraph options in code.
112
126
  """
113
127
  init()
114
128
  return _wrap(client, provider=provider)
@@ -144,26 +158,33 @@ def record_outcome(
144
158
  event_id = str(event_id or uuid.uuid4()).strip()[:128]
145
159
  try:
146
160
  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
161
  edit_distance_ratio = (
151
162
  float(edit_distance_ratio) if edit_distance_ratio is not None else None
152
163
  )
153
- regeneration_count = (
154
- int(regeneration_count) if regeneration_count is not None else None
155
- )
156
164
  except (TypeError, ValueError, OverflowError):
157
165
  return False
158
166
  if not route_name or not model or not session_key or not event_id:
159
167
  return False
160
- if feedback_score is not None and not -1 <= feedback_score <= 1:
168
+ if feedback_score is not None and (
169
+ not math.isfinite(feedback_score) or not -1 <= feedback_score <= 1
170
+ ):
161
171
  return False
162
- if turns_to_resolution is not None and not 1 <= turns_to_resolution <= 1_000_000:
172
+ if turns_to_resolution is not None and (
173
+ isinstance(turns_to_resolution, bool)
174
+ or not isinstance(turns_to_resolution, int)
175
+ or not 1 <= turns_to_resolution <= 1_000_000
176
+ ):
163
177
  return False
164
- if edit_distance_ratio is not None and not 0 <= edit_distance_ratio <= 1:
178
+ if edit_distance_ratio is not None and (
179
+ not math.isfinite(edit_distance_ratio)
180
+ or not 0 <= edit_distance_ratio <= 1
181
+ ):
165
182
  return False
166
- if regeneration_count is not None and not 0 <= regeneration_count <= 1_000_000:
183
+ if regeneration_count is not None and (
184
+ isinstance(regeneration_count, bool)
185
+ or not isinstance(regeneration_count, int)
186
+ or not 0 <= regeneration_count <= 1_000_000
187
+ ):
167
188
  return False
168
189
  if escalated is not None and not isinstance(escalated, bool):
169
190
  return False
@@ -214,6 +235,7 @@ __all__ = [
214
235
  "set_tags",
215
236
  "shutdown",
216
237
  "track",
238
+ "trace",
217
239
  "wrap",
218
240
  "wrap_executor",
219
241
  ]