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.
Files changed (22) hide show
  1. {metergraph-0.3.0 → metergraph-0.3.2}/PKG-INFO +32 -3
  2. {metergraph-0.3.0 → metergraph-0.3.2}/README.md +30 -1
  3. {metergraph-0.3.0 → metergraph-0.3.2}/pyproject.toml +2 -2
  4. {metergraph-0.3.0 → metergraph-0.3.2}/src/metergraph/__init__.py +23 -12
  5. {metergraph-0.3.0 → metergraph-0.3.2}/src/metergraph/_capture.py +115 -17
  6. {metergraph-0.3.0 → metergraph-0.3.2}/src/metergraph/_version.py +1 -1
  7. {metergraph-0.3.0 → metergraph-0.3.2}/src/metergraph.egg-info/PKG-INFO +32 -3
  8. {metergraph-0.3.0 → metergraph-0.3.2}/src/metergraph.egg-info/SOURCES.txt +1 -0
  9. metergraph-0.3.2/tests/test_edge_cases.py +556 -0
  10. {metergraph-0.3.0 → metergraph-0.3.2}/tests/test_real_client_integration.py +101 -0
  11. {metergraph-0.3.0 → metergraph-0.3.2}/tests/test_sdk.py +116 -0
  12. {metergraph-0.3.0 → metergraph-0.3.2}/setup.cfg +0 -0
  13. {metergraph-0.3.0 → metergraph-0.3.2}/src/metergraph/_config.py +0 -0
  14. {metergraph-0.3.0 → metergraph-0.3.2}/src/metergraph/_context.py +0 -0
  15. {metergraph-0.3.0 → metergraph-0.3.2}/src/metergraph/_failure_log.py +0 -0
  16. {metergraph-0.3.0 → metergraph-0.3.2}/src/metergraph/_template.py +0 -0
  17. {metergraph-0.3.0 → metergraph-0.3.2}/src/metergraph/_track.py +0 -0
  18. {metergraph-0.3.0 → metergraph-0.3.2}/src/metergraph/_transport.py +0 -0
  19. {metergraph-0.3.0 → metergraph-0.3.2}/src/metergraph.egg-info/dependency_links.txt +0 -0
  20. {metergraph-0.3.0 → metergraph-0.3.2}/src/metergraph.egg-info/requires.txt +0 -0
  21. {metergraph-0.3.0 → metergraph-0.3.2}/src/metergraph.egg-info/top_level.txt +0 -0
  22. {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.0
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.
@@ -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 Gemini clients.
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.0"
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 Google client for capture.
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. 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.
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 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
+ ):
171
171
  return False
172
- 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
+ ):
173
177
  return False
174
- 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
+ ):
175
182
  return False
176
- 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
+ ):
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
- return int(value) if value is not None else None
43
- except (TypeError, ValueError):
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.provider == "openai"
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 = "success", error: BaseException | None = None
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 = "success", error: BaseException | None = None
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(owner: Any, method_name: str, provider: str, endpoint: str) -> bool:
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
- call = runtime.call_state(provider, endpoint, request)
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
- def _apply_seams(client: Any, provider: str) -> list[str]:
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(owner, seam.method, provider, seam.endpoint):
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
- resolved_provider = provider or _detect_provider(client)
1364
- patched = _apply_seams(client, resolved_provider)
1365
- patched_count = len(patched) + _apply_batch_extras(client, resolved_provider)
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", resolved_provider
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
- resolved_provider,
1472
+ client_label,
1375
1473
  ", ".join(patched) or "(batch-only)",
1376
1474
  )
1377
1475
  except Exception:
@@ -8,4 +8,4 @@ import importlib.metadata
8
8
  try:
9
9
  SDK_VERSION = importlib.metadata.version("metergraph")
10
10
  except Exception:
11
- SDK_VERSION = "0.3.0"
11
+ SDK_VERSION = "0.3.2"
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: metergraph
3
- Version: 0.3.0
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.
@@ -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