metergraph 0.3.0__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.
- {metergraph-0.3.0 → metergraph-0.3.2}/PKG-INFO +32 -3
- {metergraph-0.3.0 → metergraph-0.3.2}/README.md +30 -1
- {metergraph-0.3.0 → metergraph-0.3.2}/pyproject.toml +2 -2
- {metergraph-0.3.0 → metergraph-0.3.2}/src/metergraph/__init__.py +23 -12
- {metergraph-0.3.0 → metergraph-0.3.2}/src/metergraph/_capture.py +115 -17
- {metergraph-0.3.0 → metergraph-0.3.2}/src/metergraph/_version.py +1 -1
- {metergraph-0.3.0 → metergraph-0.3.2}/src/metergraph.egg-info/PKG-INFO +32 -3
- {metergraph-0.3.0 → metergraph-0.3.2}/src/metergraph.egg-info/SOURCES.txt +1 -0
- metergraph-0.3.2/tests/test_edge_cases.py +556 -0
- {metergraph-0.3.0 → metergraph-0.3.2}/tests/test_real_client_integration.py +101 -0
- {metergraph-0.3.0 → metergraph-0.3.2}/tests/test_sdk.py +116 -0
- {metergraph-0.3.0 → metergraph-0.3.2}/setup.cfg +0 -0
- {metergraph-0.3.0 → metergraph-0.3.2}/src/metergraph/_config.py +0 -0
- {metergraph-0.3.0 → metergraph-0.3.2}/src/metergraph/_context.py +0 -0
- {metergraph-0.3.0 → metergraph-0.3.2}/src/metergraph/_failure_log.py +0 -0
- {metergraph-0.3.0 → metergraph-0.3.2}/src/metergraph/_template.py +0 -0
- {metergraph-0.3.0 → metergraph-0.3.2}/src/metergraph/_track.py +0 -0
- {metergraph-0.3.0 → metergraph-0.3.2}/src/metergraph/_transport.py +0 -0
- {metergraph-0.3.0 → metergraph-0.3.2}/src/metergraph.egg-info/dependency_links.txt +0 -0
- {metergraph-0.3.0 → metergraph-0.3.2}/src/metergraph.egg-info/requires.txt +0 -0
- {metergraph-0.3.0 → metergraph-0.3.2}/src/metergraph.egg-info/top_level.txt +0 -0
- {metergraph-0.3.0 → 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.3.
|
|
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
|
|
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.
|
|
@@ -51,6 +52,31 @@ metergraph.record_outcome(
|
|
|
51
52
|
)
|
|
52
53
|
```
|
|
53
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
|
+
|
|
54
80
|
Configuration:
|
|
55
81
|
|
|
56
82
|
- `METERGRAPH_APP_TOKEN` — required bearer token
|
|
@@ -101,6 +127,9 @@ Instrument this codebase's LLM API costs with the `metergraph` PyPI package
|
|
|
101
127
|
(https://github.com/PioneerSquareLabs/metergraphsdk): pip install metergraph,
|
|
102
128
|
then wrap every OpenAI()/AsyncOpenAI(), Anthropic()/AsyncAnthropic(), and
|
|
103
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.
|
|
104
133
|
wrap() returns the same client and initializes itself from the environment:
|
|
105
134
|
METERGRAPH_APP_TOKEN is required (capture is silently off without it) and
|
|
106
135
|
METERGRAPH_INGEST_URL is only for self-hosted servers. Add both to
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# metergraph (Python)
|
|
2
2
|
|
|
3
|
-
Zero-runtime-dependency capture for OpenAI, Anthropic, and
|
|
3
|
+
Zero-runtime-dependency capture for OpenAI, Anthropic, Gemini, and Python
|
|
4
|
+
Vercel AI Gateway clients.
|
|
4
5
|
`wrap()` initializes capture from the environment, so setup is one line per
|
|
5
6
|
client; call `metergraph.init(...)` before the first `wrap()` only to pass
|
|
6
7
|
options in code.
|
|
@@ -30,6 +31,31 @@ metergraph.record_outcome(
|
|
|
30
31
|
)
|
|
31
32
|
```
|
|
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
|
+
|
|
33
59
|
Configuration:
|
|
34
60
|
|
|
35
61
|
- `METERGRAPH_APP_TOKEN` — required bearer token
|
|
@@ -80,6 +106,9 @@ Instrument this codebase's LLM API costs with the `metergraph` PyPI package
|
|
|
80
106
|
(https://github.com/PioneerSquareLabs/metergraphsdk): pip install metergraph,
|
|
81
107
|
then wrap every OpenAI()/AsyncOpenAI(), Anthropic()/AsyncAnthropic(), and
|
|
82
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.
|
|
83
112
|
wrap() returns the same client and initializes itself from the environment:
|
|
84
113
|
METERGRAPH_APP_TOKEN is required (capture is silently off without it) and
|
|
85
114
|
METERGRAPH_INGEST_URL is only for self-hosted servers. Add both to
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "metergraph"
|
|
3
|
-
version = "0.3.
|
|
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
|
|
@@ -115,10 +116,13 @@ def init(
|
|
|
115
116
|
|
|
116
117
|
|
|
117
118
|
def wrap(client: Any, *, provider: str | None = None) -> Any:
|
|
118
|
-
"""Wrap an OpenAI, Anthropic, or
|
|
119
|
+
"""Wrap an OpenAI, Anthropic, Google, or Vercel AI Gateway client.
|
|
119
120
|
|
|
120
121
|
Calls init() automatically, so with env-var configuration this is the
|
|
121
|
-
only setup line needed.
|
|
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.
|
|
122
126
|
"""
|
|
123
127
|
init()
|
|
124
128
|
return _wrap(client, provider=provider)
|
|
@@ -154,26 +158,33 @@ def record_outcome(
|
|
|
154
158
|
event_id = str(event_id or uuid.uuid4()).strip()[:128]
|
|
155
159
|
try:
|
|
156
160
|
feedback_score = float(feedback_score) if feedback_score is not None else None
|
|
157
|
-
turns_to_resolution = (
|
|
158
|
-
int(turns_to_resolution) if turns_to_resolution is not None else None
|
|
159
|
-
)
|
|
160
161
|
edit_distance_ratio = (
|
|
161
162
|
float(edit_distance_ratio) if edit_distance_ratio is not None else None
|
|
162
163
|
)
|
|
163
|
-
regeneration_count = (
|
|
164
|
-
int(regeneration_count) if regeneration_count is not None else None
|
|
165
|
-
)
|
|
166
164
|
except (TypeError, ValueError, OverflowError):
|
|
167
165
|
return False
|
|
168
166
|
if not route_name or not model or not session_key or not event_id:
|
|
169
167
|
return False
|
|
170
|
-
if feedback_score is not None and
|
|
168
|
+
if feedback_score is not None and (
|
|
169
|
+
not math.isfinite(feedback_score) or not -1 <= feedback_score <= 1
|
|
170
|
+
):
|
|
171
171
|
return False
|
|
172
|
-
if turns_to_resolution is not None and
|
|
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
|
+
):
|
|
173
177
|
return False
|
|
174
|
-
if edit_distance_ratio is not None and
|
|
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
|
+
):
|
|
175
182
|
return False
|
|
176
|
-
if regeneration_count is not None and
|
|
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
|
+
):
|
|
177
188
|
return False
|
|
178
189
|
if escalated is not None and not isinstance(escalated, bool):
|
|
179
190
|
return False
|
|
@@ -6,6 +6,7 @@ import functools
|
|
|
6
6
|
import inspect
|
|
7
7
|
import json
|
|
8
8
|
import logging
|
|
9
|
+
import math
|
|
9
10
|
import os
|
|
10
11
|
import platform
|
|
11
12
|
import secrets
|
|
@@ -15,6 +16,7 @@ from dataclasses import dataclass
|
|
|
15
16
|
from datetime import datetime, timezone
|
|
16
17
|
from pathlib import Path
|
|
17
18
|
from typing import Any, Callable, Mapping
|
|
19
|
+
from urllib.parse import urlsplit
|
|
18
20
|
|
|
19
21
|
from ._context import CaptureContext, snapshot
|
|
20
22
|
from ._template import scrub, template_hash
|
|
@@ -39,8 +41,17 @@ def _first(value: Any) -> Any:
|
|
|
39
41
|
|
|
40
42
|
def _int(value: Any) -> int | None:
|
|
41
43
|
try:
|
|
42
|
-
|
|
43
|
-
|
|
44
|
+
if value is None or isinstance(value, bool):
|
|
45
|
+
return None
|
|
46
|
+
if isinstance(value, int):
|
|
47
|
+
return value if value >= 0 else None
|
|
48
|
+
parsed = float(value)
|
|
49
|
+
return (
|
|
50
|
+
int(parsed)
|
|
51
|
+
if math.isfinite(parsed) and parsed >= 0 and parsed.is_integer()
|
|
52
|
+
else None
|
|
53
|
+
)
|
|
54
|
+
except (TypeError, ValueError, OverflowError):
|
|
44
55
|
return None
|
|
45
56
|
|
|
46
57
|
|
|
@@ -202,10 +213,33 @@ def _chunk_text(chunk: Any) -> str | None:
|
|
|
202
213
|
return None
|
|
203
214
|
|
|
204
215
|
|
|
216
|
+
def _chunk_has_output(chunk: Any) -> bool:
|
|
217
|
+
"""Recognize the first user-visible text, reasoning, or tool output."""
|
|
218
|
+
if _chunk_text(chunk):
|
|
219
|
+
return True
|
|
220
|
+
for choice in _get(chunk, "choices", []) or []:
|
|
221
|
+
if _get(_get(choice, "delta"), "tool_calls"):
|
|
222
|
+
return True
|
|
223
|
+
kind = _get(chunk, "type")
|
|
224
|
+
delta = _get(chunk, "delta")
|
|
225
|
+
if isinstance(delta, str) and "reasoning" in str(kind):
|
|
226
|
+
return bool(delta)
|
|
227
|
+
if _get(delta, "thinking") or _get(delta, "reasoning"):
|
|
228
|
+
return True
|
|
229
|
+
if kind == "content_block_start":
|
|
230
|
+
return _get(_get(chunk, "content_block"), "type") == "tool_use"
|
|
231
|
+
if kind == "content_block_delta":
|
|
232
|
+
return _get(_get(chunk, "delta"), "type") == "input_json_delta"
|
|
233
|
+
for candidate in _get(chunk, "candidates", []) or []:
|
|
234
|
+
for part in _get(_get(candidate, "content"), "parts", []) or []:
|
|
235
|
+
if _get(part, "function_call") or _get(part, "functionCall"):
|
|
236
|
+
return True
|
|
237
|
+
return False
|
|
238
|
+
|
|
239
|
+
|
|
205
240
|
def _usage_only_chunk(chunk: Any, call: "CallState") -> bool:
|
|
206
241
|
return (
|
|
207
|
-
call.
|
|
208
|
-
and call.endpoint == "chat.completions"
|
|
242
|
+
call.endpoint == "chat.completions"
|
|
209
243
|
and _get(chunk, "choices") == []
|
|
210
244
|
and _get(chunk, "usage") is not None
|
|
211
245
|
)
|
|
@@ -683,6 +717,11 @@ class CallState:
|
|
|
683
717
|
}
|
|
684
718
|
for item in tool_calls
|
|
685
719
|
]
|
|
720
|
+
tool_names = (
|
|
721
|
+
list(dict.fromkeys(item["name"] for item in full_tool_calls))
|
|
722
|
+
if full_tool_calls
|
|
723
|
+
else None
|
|
724
|
+
)
|
|
686
725
|
effective_status = status or (
|
|
687
726
|
"error" if error else _stop_reason(response) or "success"
|
|
688
727
|
)
|
|
@@ -720,6 +759,7 @@ class CallState:
|
|
|
720
759
|
"unit_name": self.context.unit_name,
|
|
721
760
|
"unit_count": self.context.unit_count,
|
|
722
761
|
"tool_calls": tool_calls,
|
|
762
|
+
"tool_names": tool_names,
|
|
723
763
|
"endpoint": self.endpoint,
|
|
724
764
|
"request_id": _request_id(response),
|
|
725
765
|
"batch": self.request.get("batch") is True,
|
|
@@ -763,14 +803,14 @@ class _StreamState:
|
|
|
763
803
|
self.last = value
|
|
764
804
|
self.chunks.append(value)
|
|
765
805
|
text = _chunk_text(value)
|
|
806
|
+
if self.ttft_ms is None and _chunk_has_output(value):
|
|
807
|
+
self.ttft_ms = round((time.perf_counter() - self.call.started) * 1000)
|
|
766
808
|
if text:
|
|
767
|
-
if self.ttft_ms is None:
|
|
768
|
-
self.ttft_ms = round((time.perf_counter() - self.call.started) * 1000)
|
|
769
809
|
self.parts.append(text)
|
|
770
810
|
return value
|
|
771
811
|
|
|
772
812
|
def finish(
|
|
773
|
-
self, status: str =
|
|
813
|
+
self, status: str | None = None, error: BaseException | None = None
|
|
774
814
|
) -> None:
|
|
775
815
|
response = self.last
|
|
776
816
|
if not error:
|
|
@@ -796,7 +836,7 @@ class _StreamState:
|
|
|
796
836
|
)
|
|
797
837
|
|
|
798
838
|
async def finish_async(
|
|
799
|
-
self, status: str =
|
|
839
|
+
self, status: str | None = None, error: BaseException | None = None
|
|
800
840
|
) -> None:
|
|
801
841
|
response = self.last
|
|
802
842
|
if not error:
|
|
@@ -1198,7 +1238,14 @@ def _patch_anthropic_batch_results(owner: Any) -> bool:
|
|
|
1198
1238
|
return True
|
|
1199
1239
|
|
|
1200
1240
|
|
|
1201
|
-
def _patch(
|
|
1241
|
+
def _patch(
|
|
1242
|
+
owner: Any,
|
|
1243
|
+
method_name: str,
|
|
1244
|
+
provider: str,
|
|
1245
|
+
endpoint: str,
|
|
1246
|
+
*,
|
|
1247
|
+
gateway: bool = False,
|
|
1248
|
+
) -> bool:
|
|
1202
1249
|
original = getattr(owner, method_name, None)
|
|
1203
1250
|
if not callable(original):
|
|
1204
1251
|
return False
|
|
@@ -1223,7 +1270,8 @@ def _patch(owner: Any, method_name: str, provider: str, endpoint: str) -> bool:
|
|
|
1223
1270
|
else:
|
|
1224
1271
|
kwargs = {**kwargs, "stream_options": {"include_usage": True}}
|
|
1225
1272
|
request = _request(args, kwargs)
|
|
1226
|
-
|
|
1273
|
+
capture_provider = _gateway_provider(request) if gateway else provider
|
|
1274
|
+
call = runtime.call_state(capture_provider, endpoint, request)
|
|
1227
1275
|
try:
|
|
1228
1276
|
result = original(*args, **kwargs)
|
|
1229
1277
|
except BaseException as exc:
|
|
@@ -1321,14 +1369,44 @@ def _resolve(client: Any, path: str) -> Any:
|
|
|
1321
1369
|
return obj
|
|
1322
1370
|
|
|
1323
1371
|
|
|
1324
|
-
|
|
1372
|
+
_VERCEL_GATEWAY_HOST = "ai-gateway.vercel.sh"
|
|
1373
|
+
_VERCEL_PROVIDER_ALIASES = {"gateway", "vercel", "vercel-ai-gateway"}
|
|
1374
|
+
|
|
1375
|
+
|
|
1376
|
+
def _uses_vercel_gateway(client: Any) -> bool:
|
|
1377
|
+
"""Recognize the public AI Gateway URL exposed by supported Python SDKs."""
|
|
1378
|
+
base_url = getattr(client, "base_url", None) or getattr(
|
|
1379
|
+
client, "_base_url", None
|
|
1380
|
+
)
|
|
1381
|
+
if base_url is None:
|
|
1382
|
+
return False
|
|
1383
|
+
try:
|
|
1384
|
+
return urlsplit(str(base_url).strip()).hostname == _VERCEL_GATEWAY_HOST
|
|
1385
|
+
except (TypeError, ValueError):
|
|
1386
|
+
return False
|
|
1387
|
+
|
|
1388
|
+
|
|
1389
|
+
def _gateway_provider(request: Mapping[str, Any]) -> str:
|
|
1390
|
+
"""Use the creator in Vercel's required `creator/model` identifier."""
|
|
1391
|
+
model = str(request.get("model") or "").strip().lower()
|
|
1392
|
+
creator, separator, _ = model.partition("/")
|
|
1393
|
+
return creator if separator and creator else "vercel-ai-gateway"
|
|
1394
|
+
|
|
1395
|
+
|
|
1396
|
+
def _apply_seams(client: Any, provider: str, *, gateway: bool = False) -> list[str]:
|
|
1325
1397
|
patched: list[str] = []
|
|
1326
1398
|
for seam in SEAM_TABLES.get(provider, ()):
|
|
1327
1399
|
try:
|
|
1328
1400
|
owner = _resolve(client, seam.path)
|
|
1329
1401
|
except Exception:
|
|
1330
1402
|
continue # a pathological client property must never break wrap()
|
|
1331
|
-
if owner is not None and _patch(
|
|
1403
|
+
if owner is not None and _patch(
|
|
1404
|
+
owner,
|
|
1405
|
+
seam.method,
|
|
1406
|
+
provider,
|
|
1407
|
+
seam.endpoint,
|
|
1408
|
+
gateway=gateway,
|
|
1409
|
+
):
|
|
1332
1410
|
patched.append(f"{seam.path}.{seam.method}")
|
|
1333
1411
|
return patched
|
|
1334
1412
|
|
|
@@ -1356,22 +1434,42 @@ def _apply_batch_extras(client: Any, provider: str) -> int:
|
|
|
1356
1434
|
def wrap(client: Any, *, provider: str | None = None) -> Any:
|
|
1357
1435
|
"""Patch supported resource methods on an OpenAI, Anthropic, or Google client.
|
|
1358
1436
|
|
|
1437
|
+
OpenAI and Anthropic clients pointed at Vercel AI Gateway are recognized
|
|
1438
|
+
automatically. ``provider="vercel"`` can force gateway attribution for a
|
|
1439
|
+
compatible client whose public base URL has been customized.
|
|
1440
|
+
|
|
1359
1441
|
Never raises: an unrecognized client shape, or an exception while probing
|
|
1360
1442
|
it, results in an unmodified, uninstrumented client — not a crash.
|
|
1361
1443
|
"""
|
|
1362
1444
|
try:
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1445
|
+
gateway = (
|
|
1446
|
+
provider.strip().lower() in _VERCEL_PROVIDER_ALIASES
|
|
1447
|
+
if isinstance(provider, str)
|
|
1448
|
+
else _uses_vercel_gateway(client)
|
|
1449
|
+
)
|
|
1450
|
+
resolved_provider = (
|
|
1451
|
+
_detect_provider(client)
|
|
1452
|
+
if gateway
|
|
1453
|
+
else provider or _detect_provider(client)
|
|
1454
|
+
)
|
|
1455
|
+
patched = _apply_seams(client, resolved_provider, gateway=gateway)
|
|
1456
|
+
patched_count = len(patched)
|
|
1457
|
+
if not gateway:
|
|
1458
|
+
patched_count += _apply_batch_extras(client, resolved_provider)
|
|
1459
|
+
client_label = (
|
|
1460
|
+
f"Vercel AI Gateway via {resolved_provider}"
|
|
1461
|
+
if gateway
|
|
1462
|
+
else resolved_provider
|
|
1463
|
+
)
|
|
1366
1464
|
if not patched_count:
|
|
1367
1465
|
log.warning(
|
|
1368
|
-
"Metergraph found no supported methods on %s client",
|
|
1466
|
+
"Metergraph found no supported methods on %s client", client_label
|
|
1369
1467
|
)
|
|
1370
1468
|
else:
|
|
1371
1469
|
log.info(
|
|
1372
1470
|
"Metergraph patched %d seam(s) on %s client: %s",
|
|
1373
1471
|
patched_count,
|
|
1374
|
-
|
|
1472
|
+
client_label,
|
|
1375
1473
|
", ".join(patched) or "(batch-only)",
|
|
1376
1474
|
)
|
|
1377
1475
|
except Exception:
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: metergraph
|
|
3
|
-
Version: 0.3.
|
|
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
|
|
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.
|
|
@@ -51,6 +52,31 @@ metergraph.record_outcome(
|
|
|
51
52
|
)
|
|
52
53
|
```
|
|
53
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
|
+
|
|
54
80
|
Configuration:
|
|
55
81
|
|
|
56
82
|
- `METERGRAPH_APP_TOKEN` — required bearer token
|
|
@@ -101,6 +127,9 @@ Instrument this codebase's LLM API costs with the `metergraph` PyPI package
|
|
|
101
127
|
(https://github.com/PioneerSquareLabs/metergraphsdk): pip install metergraph,
|
|
102
128
|
then wrap every OpenAI()/AsyncOpenAI(), Anthropic()/AsyncAnthropic(), and
|
|
103
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.
|
|
104
133
|
wrap() returns the same client and initializes itself from the environment:
|
|
105
134
|
METERGRAPH_APP_TOKEN is required (capture is silently off without it) and
|
|
106
135
|
METERGRAPH_INGEST_URL is only for self-hosted servers. Add both to
|
|
@@ -14,6 +14,7 @@ src/metergraph.egg-info/SOURCES.txt
|
|
|
14
14
|
src/metergraph.egg-info/dependency_links.txt
|
|
15
15
|
src/metergraph.egg-info/requires.txt
|
|
16
16
|
src/metergraph.egg-info/top_level.txt
|
|
17
|
+
tests/test_edge_cases.py
|
|
17
18
|
tests/test_real_client_integration.py
|
|
18
19
|
tests/test_sdk.py
|
|
19
20
|
tests/test_seam_reality.py
|