metergraph 0.3.0__tar.gz → 0.4.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.
Files changed (29) hide show
  1. {metergraph-0.3.0 → metergraph-0.4.0}/PKG-INFO +42 -5
  2. {metergraph-0.3.0 → metergraph-0.4.0}/README.md +40 -3
  3. {metergraph-0.3.0 → metergraph-0.4.0}/pyproject.toml +2 -2
  4. {metergraph-0.3.0 → metergraph-0.4.0}/src/metergraph/__init__.py +48 -15
  5. {metergraph-0.3.0 → metergraph-0.4.0}/src/metergraph/_capture.py +132 -19
  6. metergraph-0.4.0/src/metergraph/_repo_config.py +165 -0
  7. metergraph-0.4.0/src/metergraph/_session.py +156 -0
  8. {metergraph-0.3.0 → metergraph-0.4.0}/src/metergraph/_transport.py +11 -1
  9. {metergraph-0.3.0 → metergraph-0.4.0}/src/metergraph/_version.py +1 -1
  10. {metergraph-0.3.0 → metergraph-0.4.0}/src/metergraph.egg-info/PKG-INFO +42 -5
  11. {metergraph-0.3.0 → metergraph-0.4.0}/src/metergraph.egg-info/SOURCES.txt +9 -1
  12. metergraph-0.4.0/tests/test_capture_repo_root.py +101 -0
  13. metergraph-0.4.0/tests/test_edge_cases.py +556 -0
  14. metergraph-0.4.0/tests/test_init_repo_aware.py +110 -0
  15. {metergraph-0.3.0 → metergraph-0.4.0}/tests/test_real_client_integration.py +101 -0
  16. metergraph-0.4.0/tests/test_repository_aware_ingest.py +221 -0
  17. {metergraph-0.3.0 → metergraph-0.4.0}/tests/test_sdk.py +116 -0
  18. metergraph-0.4.0/tests/test_session_manager.py +319 -0
  19. metergraph-0.4.0/tests/test_writer_session.py +142 -0
  20. {metergraph-0.3.0 → metergraph-0.4.0}/setup.cfg +0 -0
  21. {metergraph-0.3.0 → metergraph-0.4.0}/src/metergraph/_config.py +0 -0
  22. {metergraph-0.3.0 → metergraph-0.4.0}/src/metergraph/_context.py +0 -0
  23. {metergraph-0.3.0 → metergraph-0.4.0}/src/metergraph/_failure_log.py +0 -0
  24. {metergraph-0.3.0 → metergraph-0.4.0}/src/metergraph/_template.py +0 -0
  25. {metergraph-0.3.0 → metergraph-0.4.0}/src/metergraph/_track.py +0 -0
  26. {metergraph-0.3.0 → metergraph-0.4.0}/src/metergraph.egg-info/dependency_links.txt +0 -0
  27. {metergraph-0.3.0 → metergraph-0.4.0}/src/metergraph.egg-info/requires.txt +0 -0
  28. {metergraph-0.3.0 → metergraph-0.4.0}/src/metergraph.egg-info/top_level.txt +0 -0
  29. {metergraph-0.3.0 → metergraph-0.4.0}/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.4.0
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
@@ -59,10 +85,18 @@ Configuration:
59
85
  - `METERGRAPH_DISABLED=1` — process kill switch
60
86
  - `METERGRAPH_QUEUE_SIZE`, `METERGRAPH_BATCH_SIZE`, `METERGRAPH_FLUSH_SECONDS`
61
87
 
88
+ SDK 0.4 associates traces with their GitHub repository automatically. On the
89
+ first `init()` in a Git checkout, it reads the `origin` remote and creates
90
+ `.metergraph/config.json` at the repository root if that file is absent.
91
+ Commit this non-secret file so production can use repository-aware ingest
92
+ without Git metadata. An existing file is authoritative and is never changed
93
+ by the SDK. If discovery or creation is unavailable, ingest remains compatible
94
+ with protocol v1.
95
+
62
96
  Delivery is bounded and off the request path. Queue overflow or a collector
63
97
  outage drops capture and increments internal counters; it never changes the
64
98
  provider call. Each wire batch is bounded to 512 KiB after optional gzip.
65
- SDK 0.3 captures the scrubbed provider request and a normalized response
99
+ SDK 0.4 captures the scrubbed provider request and a normalized response
66
100
  envelope, including assistant content and tool calls, by default. Provider
67
101
  credentials and transport headers are removed. Request and response are each
68
102
  limited to 100 KiB of UTF-8 with an explicit truncation marker.
@@ -101,10 +135,13 @@ Instrument this codebase's LLM API costs with the `metergraph` PyPI package
101
135
  (https://github.com/PioneerSquareLabs/metergraphsdk): pip install metergraph,
102
136
  then wrap every OpenAI()/AsyncOpenAI(), Anthropic()/AsyncAnthropic(), and
103
137
  genai.Client() construction in place, e.g. client = metergraph.wrap(OpenAI()).
138
+ OpenAI or Anthropic clients pointed at https://ai-gateway.vercel.sh are Vercel
139
+ AI Gateway clients and are detected automatically; keep their creator/model ID
140
+ and AI_GATEWAY_API_KEY / VERCEL_OIDC_TOKEN configuration unchanged.
104
141
  wrap() returns the same client and initializes itself from the environment:
105
142
  METERGRAPH_APP_TOKEN is required (capture is silently off without it) and
106
143
  METERGRAPH_INGEST_URL is only for self-hosted servers. Add both to
107
- .env.example, and never commit a real token. SDK 0.3 captures scrubbed provider
144
+ .env.example, and never commit a real token. SDK 0.4 captures scrubbed provider
108
145
  requests and normalized responses by default for the hosted dashboard; use
109
146
  METERGRAPH_CAPTURE_TEXT=0 or capture_text=False around sensitive operations.
110
147
  Provider credentials and transport headers must never be captured. Capture is
@@ -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
@@ -38,10 +64,18 @@ Configuration:
38
64
  - `METERGRAPH_DISABLED=1` — process kill switch
39
65
  - `METERGRAPH_QUEUE_SIZE`, `METERGRAPH_BATCH_SIZE`, `METERGRAPH_FLUSH_SECONDS`
40
66
 
67
+ SDK 0.4 associates traces with their GitHub repository automatically. On the
68
+ first `init()` in a Git checkout, it reads the `origin` remote and creates
69
+ `.metergraph/config.json` at the repository root if that file is absent.
70
+ Commit this non-secret file so production can use repository-aware ingest
71
+ without Git metadata. An existing file is authoritative and is never changed
72
+ by the SDK. If discovery or creation is unavailable, ingest remains compatible
73
+ with protocol v1.
74
+
41
75
  Delivery is bounded and off the request path. Queue overflow or a collector
42
76
  outage drops capture and increments internal counters; it never changes the
43
77
  provider call. Each wire batch is bounded to 512 KiB after optional gzip.
44
- SDK 0.3 captures the scrubbed provider request and a normalized response
78
+ SDK 0.4 captures the scrubbed provider request and a normalized response
45
79
  envelope, including assistant content and tool calls, by default. Provider
46
80
  credentials and transport headers are removed. Request and response are each
47
81
  limited to 100 KiB of UTF-8 with an explicit truncation marker.
@@ -80,10 +114,13 @@ Instrument this codebase's LLM API costs with the `metergraph` PyPI package
80
114
  (https://github.com/PioneerSquareLabs/metergraphsdk): pip install metergraph,
81
115
  then wrap every OpenAI()/AsyncOpenAI(), Anthropic()/AsyncAnthropic(), and
82
116
  genai.Client() construction in place, e.g. client = metergraph.wrap(OpenAI()).
117
+ OpenAI or Anthropic clients pointed at https://ai-gateway.vercel.sh are Vercel
118
+ AI Gateway clients and are detected automatically; keep their creator/model ID
119
+ and AI_GATEWAY_API_KEY / VERCEL_OIDC_TOKEN configuration unchanged.
83
120
  wrap() returns the same client and initializes itself from the environment:
84
121
  METERGRAPH_APP_TOKEN is required (capture is silently off without it) and
85
122
  METERGRAPH_INGEST_URL is only for self-hosted servers. Add both to
86
- .env.example, and never commit a real token. SDK 0.3 captures scrubbed provider
123
+ .env.example, and never commit a real token. SDK 0.4 captures scrubbed provider
87
124
  requests and normalized responses by default for the hosted dashboard; use
88
125
  METERGRAPH_CAPTURE_TEXT=0 or capture_text=False around sensitive operations.
89
126
  Provider credentials and transport headers must never be captured. Capture is
@@ -1,12 +1,12 @@
1
1
  [project]
2
2
  name = "metergraph"
3
- version = "0.3.0"
3
+ version = "0.4.0"
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
@@ -13,6 +14,8 @@ from ._capture import Options, Runtime, set_runtime
13
14
  from ._capture import wrap as _wrap
14
15
  from ._config import ConfigPoller
15
16
  from ._context import route, set_session, set_tags, snapshot, trace, wrap_executor
17
+ from ._repo_config import ensure_repo_config
18
+ from ._session import SessionManager
16
19
  from ._track import track
17
20
  from ._transport import Writer
18
21
  from ._version import SDK_VERSION
@@ -23,6 +26,7 @@ DEFAULT_INGEST_URL = "https://d2xus7mp8zdv6t.cloudfront.net"
23
26
  log = logging.getLogger("metergraph")
24
27
  _writer: Writer | None = None
25
28
  _config: ConfigPoller | None = None
29
+ _session_manager: SessionManager | None = None
26
30
  _initialized = False
27
31
  _warned_no_token = False
28
32
 
@@ -46,7 +50,7 @@ def init(
46
50
  disabled: bool | None = None,
47
51
  ) -> None:
48
52
  """Initialize capture. This function is idempotent and never raises."""
49
- global _initialized, _warned_no_token, _writer, _config
53
+ global _initialized, _warned_no_token, _writer, _config, _session_manager
50
54
  if _initialized:
51
55
  return
52
56
  if os.getenv("METERGRAPH_DISABLED") == "1" or disabled:
@@ -64,9 +68,23 @@ def init(
64
68
  return
65
69
  _initialized = True
66
70
  try:
71
+ app_root_resolved = os.path.realpath(app_root or os.getcwd())
72
+ repo_config = ensure_repo_config(app_root_resolved)
73
+ session = (
74
+ SessionManager(
75
+ token,
76
+ ingest_url,
77
+ repository=repo_config.repository,
78
+ sdk_version=SDK_VERSION,
79
+ )
80
+ if repo_config is not None
81
+ else None
82
+ )
83
+ _session_manager = session
67
84
  _writer = Writer(
68
85
  token,
69
86
  ingest_url,
87
+ session=session,
70
88
  queue_size=int(os.getenv("METERGRAPH_QUEUE_SIZE", "2000")),
71
89
  batch_size=int(os.getenv("METERGRAPH_BATCH_SIZE", "100")),
72
90
  flush_seconds=float(os.getenv("METERGRAPH_FLUSH_SECONDS", "5")),
@@ -78,7 +96,8 @@ def init(
78
96
  else capture_text
79
97
  ),
80
98
  redact=redact,
81
- app_root=os.path.realpath(app_root or os.getcwd()),
99
+ app_root=app_root_resolved,
100
+ repo_root=repo_config.repo_root if repo_config is not None else None,
82
101
  skip_frames=tuple(skip_frames or ()),
83
102
  environment=environment or os.getenv("METERGRAPH_ENV"),
84
103
  text_max_bytes=min(
@@ -109,16 +128,20 @@ def init(
109
128
  _writer.shutdown()
110
129
  _writer = None
111
130
  _config = None
131
+ _session_manager = None
112
132
  log.warning(
113
133
  "Metergraph initialization failed; application is running uninstrumented"
114
134
  )
115
135
 
116
136
 
117
137
  def wrap(client: Any, *, provider: str | None = None) -> Any:
118
- """Wrap an OpenAI, Anthropic, or Google client for capture.
138
+ """Wrap an OpenAI, Anthropic, Google, or Vercel AI Gateway client.
119
139
 
120
140
  Calls init() automatically, so with env-var configuration this is the
121
- only setup line needed. Call init(...) first to pass options in code.
141
+ only setup line needed. OpenAI and Anthropic clients using Vercel's public
142
+ AI Gateway URL are detected automatically; pass ``provider="vercel"`` to
143
+ force gateway handling for a compatible client with a custom URL. Call
144
+ init(...) first to pass Metergraph options in code.
122
145
  """
123
146
  init()
124
147
  return _wrap(client, provider=provider)
@@ -154,26 +177,33 @@ def record_outcome(
154
177
  event_id = str(event_id or uuid.uuid4()).strip()[:128]
155
178
  try:
156
179
  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
180
  edit_distance_ratio = (
161
181
  float(edit_distance_ratio) if edit_distance_ratio is not None else None
162
182
  )
163
- regeneration_count = (
164
- int(regeneration_count) if regeneration_count is not None else None
165
- )
166
183
  except (TypeError, ValueError, OverflowError):
167
184
  return False
168
185
  if not route_name or not model or not session_key or not event_id:
169
186
  return False
170
- if feedback_score is not None and not -1 <= feedback_score <= 1:
187
+ if feedback_score is not None and (
188
+ not math.isfinite(feedback_score) or not -1 <= feedback_score <= 1
189
+ ):
171
190
  return False
172
- if turns_to_resolution is not None and not 1 <= turns_to_resolution <= 1_000_000:
191
+ if turns_to_resolution is not None and (
192
+ isinstance(turns_to_resolution, bool)
193
+ or not isinstance(turns_to_resolution, int)
194
+ or not 1 <= turns_to_resolution <= 1_000_000
195
+ ):
173
196
  return False
174
- if edit_distance_ratio is not None and not 0 <= edit_distance_ratio <= 1:
197
+ if edit_distance_ratio is not None and (
198
+ not math.isfinite(edit_distance_ratio)
199
+ or not 0 <= edit_distance_ratio <= 1
200
+ ):
175
201
  return False
176
- if regeneration_count is not None and not 0 <= regeneration_count <= 1_000_000:
202
+ if regeneration_count is not None and (
203
+ isinstance(regeneration_count, bool)
204
+ or not isinstance(regeneration_count, int)
205
+ or not 0 <= regeneration_count <= 1_000_000
206
+ ):
177
207
  return False
178
208
  if escalated is not None and not isinstance(escalated, bool):
179
209
  return False
@@ -203,13 +233,16 @@ def flush(timeout: float = 3.0) -> bool:
203
233
 
204
234
 
205
235
  def shutdown() -> None:
206
- global _writer, _config
236
+ global _writer, _config, _session_manager
207
237
  if _config:
208
238
  _config.stop()
209
239
  _config = None
210
240
  if _writer:
211
241
  _writer.shutdown()
212
242
  _writer = None
243
+ if _session_manager:
244
+ _session_manager.stop()
245
+ _session_manager = None
213
246
  set_runtime(None)
214
247
 
215
248
 
@@ -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
  )
@@ -522,10 +556,11 @@ def _tool_events(
522
556
 
523
557
 
524
558
  def _capture_frames(
525
- app_root: str, skip_frames: tuple[str, ...]
559
+ app_root: str, skip_frames: tuple[str, ...], repo_root: str | None = None
526
560
  ) -> tuple[str | None, str | None, list[dict]]:
527
561
  frames: list[dict] = []
528
562
  root = os.path.realpath(app_root)
563
+ repo_root_real = os.path.realpath(repo_root) if repo_root else None
529
564
  frame = sys._getframe(2)
530
565
  while frame is not None and len(frames) < 5:
531
566
  filename = os.path.realpath(frame.f_code.co_filename)
@@ -535,7 +570,19 @@ def _capture_frames(
535
570
  relative = os.path.relpath(filename, root)
536
571
  module = str(Path(relative).with_suffix("")).replace(os.sep, ".")
537
572
  qualname = getattr(frame.f_code, "co_qualname", frame.f_code.co_name)
538
- frames.append({"m": module, "f": qualname, "l": frame.f_lineno})
573
+ entry = {"m": module, "f": qualname, "l": frame.f_lineno}
574
+ try:
575
+ inside_repo = bool(
576
+ repo_root_real
577
+ and os.path.commonpath((filename, repo_root_real)) == repo_root_real
578
+ )
579
+ except ValueError:
580
+ inside_repo = False
581
+ if inside_repo:
582
+ entry["p"] = os.path.relpath(filename, repo_root_real).replace(
583
+ os.sep, "/"
584
+ )
585
+ frames.append(entry)
539
586
  frame = frame.f_back
540
587
  if not frames:
541
588
  return None, None, []
@@ -547,6 +594,7 @@ class Options:
547
594
  capture_text: bool = True
548
595
  redact: Callable[[str, str], str] | None = None
549
596
  app_root: str = os.getcwd()
597
+ repo_root: str | None = None
550
598
  skip_frames: tuple[str, ...] = ()
551
599
  environment: str | None = None
552
600
  text_max_bytes: int = 100 * 1024
@@ -575,6 +623,7 @@ class Runtime:
575
623
  "threading.py",
576
624
  *self.options.skip_frames,
577
625
  ),
626
+ self.options.repo_root,
578
627
  )
579
628
  return CallState(
580
629
  runtime=self,
@@ -683,6 +732,11 @@ class CallState:
683
732
  }
684
733
  for item in tool_calls
685
734
  ]
735
+ tool_names = (
736
+ list(dict.fromkeys(item["name"] for item in full_tool_calls))
737
+ if full_tool_calls
738
+ else None
739
+ )
686
740
  effective_status = status or (
687
741
  "error" if error else _stop_reason(response) or "success"
688
742
  )
@@ -720,6 +774,7 @@ class CallState:
720
774
  "unit_name": self.context.unit_name,
721
775
  "unit_count": self.context.unit_count,
722
776
  "tool_calls": tool_calls,
777
+ "tool_names": tool_names,
723
778
  "endpoint": self.endpoint,
724
779
  "request_id": _request_id(response),
725
780
  "batch": self.request.get("batch") is True,
@@ -763,14 +818,14 @@ class _StreamState:
763
818
  self.last = value
764
819
  self.chunks.append(value)
765
820
  text = _chunk_text(value)
821
+ if self.ttft_ms is None and _chunk_has_output(value):
822
+ self.ttft_ms = round((time.perf_counter() - self.call.started) * 1000)
766
823
  if text:
767
- if self.ttft_ms is None:
768
- self.ttft_ms = round((time.perf_counter() - self.call.started) * 1000)
769
824
  self.parts.append(text)
770
825
  return value
771
826
 
772
827
  def finish(
773
- self, status: str = "success", error: BaseException | None = None
828
+ self, status: str | None = None, error: BaseException | None = None
774
829
  ) -> None:
775
830
  response = self.last
776
831
  if not error:
@@ -796,7 +851,7 @@ class _StreamState:
796
851
  )
797
852
 
798
853
  async def finish_async(
799
- self, status: str = "success", error: BaseException | None = None
854
+ self, status: str | None = None, error: BaseException | None = None
800
855
  ) -> None:
801
856
  response = self.last
802
857
  if not error:
@@ -1198,7 +1253,14 @@ def _patch_anthropic_batch_results(owner: Any) -> bool:
1198
1253
  return True
1199
1254
 
1200
1255
 
1201
- def _patch(owner: Any, method_name: str, provider: str, endpoint: str) -> bool:
1256
+ def _patch(
1257
+ owner: Any,
1258
+ method_name: str,
1259
+ provider: str,
1260
+ endpoint: str,
1261
+ *,
1262
+ gateway: bool = False,
1263
+ ) -> bool:
1202
1264
  original = getattr(owner, method_name, None)
1203
1265
  if not callable(original):
1204
1266
  return False
@@ -1223,7 +1285,8 @@ def _patch(owner: Any, method_name: str, provider: str, endpoint: str) -> bool:
1223
1285
  else:
1224
1286
  kwargs = {**kwargs, "stream_options": {"include_usage": True}}
1225
1287
  request = _request(args, kwargs)
1226
- call = runtime.call_state(provider, endpoint, request)
1288
+ capture_provider = _gateway_provider(request) if gateway else provider
1289
+ call = runtime.call_state(capture_provider, endpoint, request)
1227
1290
  try:
1228
1291
  result = original(*args, **kwargs)
1229
1292
  except BaseException as exc:
@@ -1321,14 +1384,44 @@ def _resolve(client: Any, path: str) -> Any:
1321
1384
  return obj
1322
1385
 
1323
1386
 
1324
- def _apply_seams(client: Any, provider: str) -> list[str]:
1387
+ _VERCEL_GATEWAY_HOST = "ai-gateway.vercel.sh"
1388
+ _VERCEL_PROVIDER_ALIASES = {"gateway", "vercel", "vercel-ai-gateway"}
1389
+
1390
+
1391
+ def _uses_vercel_gateway(client: Any) -> bool:
1392
+ """Recognize the public AI Gateway URL exposed by supported Python SDKs."""
1393
+ base_url = getattr(client, "base_url", None) or getattr(
1394
+ client, "_base_url", None
1395
+ )
1396
+ if base_url is None:
1397
+ return False
1398
+ try:
1399
+ return urlsplit(str(base_url).strip()).hostname == _VERCEL_GATEWAY_HOST
1400
+ except (TypeError, ValueError):
1401
+ return False
1402
+
1403
+
1404
+ def _gateway_provider(request: Mapping[str, Any]) -> str:
1405
+ """Use the creator in Vercel's required `creator/model` identifier."""
1406
+ model = str(request.get("model") or "").strip().lower()
1407
+ creator, separator, _ = model.partition("/")
1408
+ return creator if separator and creator else "vercel-ai-gateway"
1409
+
1410
+
1411
+ def _apply_seams(client: Any, provider: str, *, gateway: bool = False) -> list[str]:
1325
1412
  patched: list[str] = []
1326
1413
  for seam in SEAM_TABLES.get(provider, ()):
1327
1414
  try:
1328
1415
  owner = _resolve(client, seam.path)
1329
1416
  except Exception:
1330
1417
  continue # a pathological client property must never break wrap()
1331
- if owner is not None and _patch(owner, seam.method, provider, seam.endpoint):
1418
+ if owner is not None and _patch(
1419
+ owner,
1420
+ seam.method,
1421
+ provider,
1422
+ seam.endpoint,
1423
+ gateway=gateway,
1424
+ ):
1332
1425
  patched.append(f"{seam.path}.{seam.method}")
1333
1426
  return patched
1334
1427
 
@@ -1356,22 +1449,42 @@ def _apply_batch_extras(client: Any, provider: str) -> int:
1356
1449
  def wrap(client: Any, *, provider: str | None = None) -> Any:
1357
1450
  """Patch supported resource methods on an OpenAI, Anthropic, or Google client.
1358
1451
 
1452
+ OpenAI and Anthropic clients pointed at Vercel AI Gateway are recognized
1453
+ automatically. ``provider="vercel"`` can force gateway attribution for a
1454
+ compatible client whose public base URL has been customized.
1455
+
1359
1456
  Never raises: an unrecognized client shape, or an exception while probing
1360
1457
  it, results in an unmodified, uninstrumented client — not a crash.
1361
1458
  """
1362
1459
  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)
1460
+ gateway = (
1461
+ provider.strip().lower() in _VERCEL_PROVIDER_ALIASES
1462
+ if isinstance(provider, str)
1463
+ else _uses_vercel_gateway(client)
1464
+ )
1465
+ resolved_provider = (
1466
+ _detect_provider(client)
1467
+ if gateway
1468
+ else provider or _detect_provider(client)
1469
+ )
1470
+ patched = _apply_seams(client, resolved_provider, gateway=gateway)
1471
+ patched_count = len(patched)
1472
+ if not gateway:
1473
+ patched_count += _apply_batch_extras(client, resolved_provider)
1474
+ client_label = (
1475
+ f"Vercel AI Gateway via {resolved_provider}"
1476
+ if gateway
1477
+ else resolved_provider
1478
+ )
1366
1479
  if not patched_count:
1367
1480
  log.warning(
1368
- "Metergraph found no supported methods on %s client", resolved_provider
1481
+ "Metergraph found no supported methods on %s client", client_label
1369
1482
  )
1370
1483
  else:
1371
1484
  log.info(
1372
1485
  "Metergraph patched %d seam(s) on %s client: %s",
1373
1486
  patched_count,
1374
- resolved_provider,
1487
+ client_label,
1375
1488
  ", ".join(patched) or "(batch-only)",
1376
1489
  )
1377
1490
  except Exception: