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.
- {metergraph-0.3.0 → metergraph-0.4.0}/PKG-INFO +42 -5
- {metergraph-0.3.0 → metergraph-0.4.0}/README.md +40 -3
- {metergraph-0.3.0 → metergraph-0.4.0}/pyproject.toml +2 -2
- {metergraph-0.3.0 → metergraph-0.4.0}/src/metergraph/__init__.py +48 -15
- {metergraph-0.3.0 → metergraph-0.4.0}/src/metergraph/_capture.py +132 -19
- metergraph-0.4.0/src/metergraph/_repo_config.py +165 -0
- metergraph-0.4.0/src/metergraph/_session.py +156 -0
- {metergraph-0.3.0 → metergraph-0.4.0}/src/metergraph/_transport.py +11 -1
- {metergraph-0.3.0 → metergraph-0.4.0}/src/metergraph/_version.py +1 -1
- {metergraph-0.3.0 → metergraph-0.4.0}/src/metergraph.egg-info/PKG-INFO +42 -5
- {metergraph-0.3.0 → metergraph-0.4.0}/src/metergraph.egg-info/SOURCES.txt +9 -1
- metergraph-0.4.0/tests/test_capture_repo_root.py +101 -0
- metergraph-0.4.0/tests/test_edge_cases.py +556 -0
- metergraph-0.4.0/tests/test_init_repo_aware.py +110 -0
- {metergraph-0.3.0 → metergraph-0.4.0}/tests/test_real_client_integration.py +101 -0
- metergraph-0.4.0/tests/test_repository_aware_ingest.py +221 -0
- {metergraph-0.3.0 → metergraph-0.4.0}/tests/test_sdk.py +116 -0
- metergraph-0.4.0/tests/test_session_manager.py +319 -0
- metergraph-0.4.0/tests/test_writer_session.py +142 -0
- {metergraph-0.3.0 → metergraph-0.4.0}/setup.cfg +0 -0
- {metergraph-0.3.0 → metergraph-0.4.0}/src/metergraph/_config.py +0 -0
- {metergraph-0.3.0 → metergraph-0.4.0}/src/metergraph/_context.py +0 -0
- {metergraph-0.3.0 → metergraph-0.4.0}/src/metergraph/_failure_log.py +0 -0
- {metergraph-0.3.0 → metergraph-0.4.0}/src/metergraph/_template.py +0 -0
- {metergraph-0.3.0 → metergraph-0.4.0}/src/metergraph/_track.py +0 -0
- {metergraph-0.3.0 → metergraph-0.4.0}/src/metergraph.egg-info/dependency_links.txt +0 -0
- {metergraph-0.3.0 → metergraph-0.4.0}/src/metergraph.egg-info/requires.txt +0 -0
- {metergraph-0.3.0 → metergraph-0.4.0}/src/metergraph.egg-info/top_level.txt +0 -0
- {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
|
+
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
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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
|
+
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=
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
)
|
|
@@ -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
|
-
|
|
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 =
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
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",
|
|
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
|
-
|
|
1487
|
+
client_label,
|
|
1375
1488
|
", ".join(patched) or "(batch-only)",
|
|
1376
1489
|
)
|
|
1377
1490
|
except Exception:
|