metergraph 0.6.0__tar.gz → 0.6.3__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.6.0 → metergraph-0.6.3}/PKG-INFO +58 -10
- metergraph-0.6.0/src/metergraph.egg-info/PKG-INFO → metergraph-0.6.3/README.md +52 -29
- {metergraph-0.6.0 → metergraph-0.6.3}/pyproject.toml +3 -2
- {metergraph-0.6.0 → metergraph-0.6.3}/src/metergraph/__init__.py +26 -11
- {metergraph-0.6.0 → metergraph-0.6.3}/src/metergraph/_capture.py +93 -12
- {metergraph-0.6.0 → metergraph-0.6.3}/src/metergraph/_context.py +117 -11
- metergraph-0.6.3/src/metergraph/_repo_config.py +63 -0
- {metergraph-0.6.0 → metergraph-0.6.3}/src/metergraph/_transport.py +1 -1
- {metergraph-0.6.0 → metergraph-0.6.3}/src/metergraph/_version.py +1 -1
- metergraph-0.6.3/src/metergraph/opentelemetry.py +221 -0
- metergraph-0.6.0/README.md → metergraph-0.6.3/src/metergraph.egg-info/PKG-INFO +77 -7
- {metergraph-0.6.0 → metergraph-0.6.3}/src/metergraph.egg-info/SOURCES.txt +1 -0
- metergraph-0.6.3/src/metergraph.egg-info/requires.txt +11 -0
- metergraph-0.6.0/src/metergraph/_repo_config.py +0 -165
- metergraph-0.6.0/src/metergraph.egg-info/requires.txt +0 -7
- {metergraph-0.6.0 → metergraph-0.6.3}/MANIFEST.in +0 -0
- {metergraph-0.6.0 → metergraph-0.6.3}/setup.cfg +0 -0
- {metergraph-0.6.0 → metergraph-0.6.3}/src/metergraph/_batch_first.py +0 -0
- {metergraph-0.6.0 → metergraph-0.6.3}/src/metergraph/_config.py +0 -0
- {metergraph-0.6.0 → metergraph-0.6.3}/src/metergraph/_failure_log.py +0 -0
- {metergraph-0.6.0 → metergraph-0.6.3}/src/metergraph/_provider_batch.py +0 -0
- {metergraph-0.6.0 → metergraph-0.6.3}/src/metergraph/_session.py +0 -0
- {metergraph-0.6.0 → metergraph-0.6.3}/src/metergraph/_template.py +0 -0
- {metergraph-0.6.0 → metergraph-0.6.3}/src/metergraph/_track.py +0 -0
- {metergraph-0.6.0 → metergraph-0.6.3}/src/metergraph.egg-info/dependency_links.txt +0 -0
- {metergraph-0.6.0 → metergraph-0.6.3}/src/metergraph.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: metergraph
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.3
|
|
4
4
|
Summary: Fire-and-forget LLM spend capture for Metergraph
|
|
5
5
|
Author: Pioneer Square Labs
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -13,12 +13,15 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
|
13
13
|
Classifier: Typing :: Typed
|
|
14
14
|
Requires-Python: >=3.10
|
|
15
15
|
Description-Content-Type: text/markdown
|
|
16
|
+
Provides-Extra: otel
|
|
17
|
+
Requires-Dist: opentelemetry-sdk>=1.30; extra == "otel"
|
|
16
18
|
Provides-Extra: dev
|
|
17
19
|
Requires-Dist: build>=1; extra == "dev"
|
|
18
20
|
Requires-Dist: pytest>=8; extra == "dev"
|
|
19
|
-
Requires-Dist: openai
|
|
20
|
-
Requires-Dist: anthropic
|
|
21
|
+
Requires-Dist: openai<3,>=2.50.0; extra == "dev"
|
|
22
|
+
Requires-Dist: anthropic<1,>=0.40; extra == "dev"
|
|
21
23
|
Requires-Dist: google-genai>=1; extra == "dev"
|
|
24
|
+
Requires-Dist: opentelemetry-sdk>=1.30; extra == "dev"
|
|
22
25
|
|
|
23
26
|
# metergraph (Python)
|
|
24
27
|
|
|
@@ -38,12 +41,15 @@ from openai import OpenAI
|
|
|
38
41
|
metergraph.init(repository="owner/repository")
|
|
39
42
|
# Anthropic() and google-genai's genai.Client() wrap the same way.
|
|
40
43
|
client = metergraph.wrap(OpenAI())
|
|
41
|
-
metergraph.set_session("ticket-123")
|
|
42
44
|
|
|
43
|
-
with metergraph.
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
with metergraph.context(
|
|
46
|
+
session_id="ticket-123",
|
|
47
|
+
tags={"customer": "acme"},
|
|
48
|
+
):
|
|
49
|
+
with metergraph.trace("ticket-workflow"):
|
|
50
|
+
with metergraph.route("ticket-classifier", unit="answer"):
|
|
51
|
+
model = metergraph.model_for("ticket-classifier", default="gpt-4.1-mini")
|
|
52
|
+
client.chat.completions.create(model=model, messages=[...])
|
|
47
53
|
|
|
48
54
|
# Emit this after the user-visible task resolves. It shares the bounded async
|
|
49
55
|
# transport and contains no prompt or output content.
|
|
@@ -57,6 +63,14 @@ metergraph.record_outcome(
|
|
|
57
63
|
)
|
|
58
64
|
```
|
|
59
65
|
|
|
66
|
+
Use `metergraph.context()` for request or job identity. It follows async work
|
|
67
|
+
created inside the scope and is restored afterward, so concurrent and reused
|
|
68
|
+
workers cannot leak session IDs or tags into one another. The narrower
|
|
69
|
+
`metergraph.session()` and `metergraph.tags()` scopes compose with it.
|
|
70
|
+
`metergraph.set_default_tags()` sets process-wide service metadata. Legacy
|
|
71
|
+
`set_session()` and `set_tags()` calls only update an active Metergraph scope;
|
|
72
|
+
outside one they warn once and do nothing.
|
|
73
|
+
|
|
60
74
|
Vercel's supported Python surface is AI Gateway through the OpenAI or
|
|
61
75
|
Anthropic SDK. Point either client at the public gateway and `wrap()` detects
|
|
62
76
|
it automatically:
|
|
@@ -83,12 +97,44 @@ async, streaming, tool calls, and OpenAI Responses API calls are captured. Use
|
|
|
83
97
|
`metergraph.wrap(client, provider="vercel")` only when a compatible client is
|
|
84
98
|
behind a custom gateway URL that cannot be detected automatically.
|
|
85
99
|
|
|
100
|
+
## OpenTelemetry GenAI export
|
|
101
|
+
|
|
102
|
+
`MetergraphGenAIExporter` is a standard OpenTelemetry span exporter for GenAI
|
|
103
|
+
semantic-convention spans. The currently qualified integration is LiteLLM:
|
|
104
|
+
install the optional integration and configure MeterGraph as LiteLLM's custom
|
|
105
|
+
exporter. Existing LiteLLM call sites remain unchanged and must not also be
|
|
106
|
+
wrapped.
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
python -m pip install 'metergraph[otel]' 'litellm[proxy]>=1.96.2,<2'
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
import litellm
|
|
114
|
+
from litellm.integrations.opentelemetry import OpenTelemetry, OpenTelemetryConfig
|
|
115
|
+
from metergraph.opentelemetry import MetergraphGenAIExporter
|
|
116
|
+
|
|
117
|
+
litellm.callbacks.append(OpenTelemetry(OpenTelemetryConfig(
|
|
118
|
+
exporter=MetergraphGenAIExporter(),
|
|
119
|
+
capture_message_content="SPAN_ONLY",
|
|
120
|
+
)))
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The exporter preserves OpenTelemetry trace identity, model/provider metadata,
|
|
124
|
+
token usage, latency, system instructions, ordered messages, and text output.
|
|
125
|
+
Message content is explicitly enabled because it may be sensitive. Text parts
|
|
126
|
+
are retained and replayable in the current POC pipeline. Calls containing other
|
|
127
|
+
part types retain their model, usage, timing, and status metadata, but those
|
|
128
|
+
parts are not replayable yet. See the runnable
|
|
129
|
+
[`python-litellm-otel` example](../examples/python-litellm-otel/).
|
|
130
|
+
|
|
86
131
|
Configuration:
|
|
87
132
|
|
|
88
133
|
- `METERGRAPH_APP_TOKEN` — required bearer token
|
|
89
134
|
- `METERGRAPH_INGEST_URL` — optional override; defaults to the hosted HTTPS endpoint
|
|
90
135
|
- `METERGRAPH_REPOSITORY` — optional `owner/repository` identity; used by [MeterGraph Bot](https://github.com/apps/metergraph)
|
|
91
136
|
- `METERGRAPH_CAPTURE_TEXT=0` — opt out of content capture globally
|
|
137
|
+
- `METERGRAPH_TEXT_MAX_BYTES` — per-field content limit; defaults to 1 MiB
|
|
92
138
|
- `METERGRAPH_DISABLED=1` — process kill switch
|
|
93
139
|
- `METERGRAPH_QUEUE_SIZE`, `METERGRAPH_BATCH_SIZE`, `METERGRAPH_FLUSH_SECONDS`
|
|
94
140
|
|
|
@@ -110,11 +156,13 @@ identity, it warns once and continues capture without repository attribution.
|
|
|
110
156
|
|
|
111
157
|
Delivery is bounded and off the request path. Queue overflow or a collector
|
|
112
158
|
outage drops capture and increments internal counters; it never changes the
|
|
113
|
-
provider call. Each wire batch is bounded to
|
|
159
|
+
provider call. Each wire batch is bounded to 4 MiB after optional gzip.
|
|
114
160
|
By default, Metergraph captures the scrubbed provider request and a normalized
|
|
115
161
|
response envelope, including assistant content and tool calls. Provider
|
|
116
162
|
credentials and transport headers are removed. Request and response are each
|
|
117
|
-
limited to
|
|
163
|
+
limited to 1 MiB of UTF-8 by default with an explicit truncation marker. Set
|
|
164
|
+
`METERGRAPH_TEXT_MAX_BYTES` or initialize with `text_max_bytes=...` to raise
|
|
165
|
+
the per-field limit for larger prompts and responses.
|
|
118
166
|
`capture_text=False` on `route()` or `trace()` overrides the global content
|
|
119
167
|
policy for a sensitive operation. The equivalent initialization option is
|
|
120
168
|
`metergraph.init(capture_text=False)`. The public open-source server continues
|
|
@@ -1,25 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: metergraph
|
|
3
|
-
Version: 0.6.0
|
|
4
|
-
Summary: Fire-and-forget LLM spend capture for Metergraph
|
|
5
|
-
Author: Pioneer Square Labs
|
|
6
|
-
License-Expression: Apache-2.0
|
|
7
|
-
Project-URL: Homepage, https://www.metergraph.dev/
|
|
8
|
-
Project-URL: Repository, https://github.com/PioneerSquareLabs/metergraphsdk
|
|
9
|
-
Project-URL: Issues, https://github.com/PioneerSquareLabs/metergraphsdk/issues
|
|
10
|
-
Keywords: llm,observability,openai,anthropic,gemini,vercel-ai-gateway,cost-tracking
|
|
11
|
-
Classifier: Intended Audience :: Developers
|
|
12
|
-
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
13
|
-
Classifier: Typing :: Typed
|
|
14
|
-
Requires-Python: >=3.10
|
|
15
|
-
Description-Content-Type: text/markdown
|
|
16
|
-
Provides-Extra: dev
|
|
17
|
-
Requires-Dist: build>=1; extra == "dev"
|
|
18
|
-
Requires-Dist: pytest>=8; extra == "dev"
|
|
19
|
-
Requires-Dist: openai>=2.50.0; extra == "dev"
|
|
20
|
-
Requires-Dist: anthropic>=0.40; extra == "dev"
|
|
21
|
-
Requires-Dist: google-genai>=1; extra == "dev"
|
|
22
|
-
|
|
23
1
|
# metergraph (Python)
|
|
24
2
|
|
|
25
3
|
Zero-runtime-dependency capture for OpenAI, Anthropic, Gemini, and Python
|
|
@@ -38,12 +16,15 @@ from openai import OpenAI
|
|
|
38
16
|
metergraph.init(repository="owner/repository")
|
|
39
17
|
# Anthropic() and google-genai's genai.Client() wrap the same way.
|
|
40
18
|
client = metergraph.wrap(OpenAI())
|
|
41
|
-
metergraph.set_session("ticket-123")
|
|
42
19
|
|
|
43
|
-
with metergraph.
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
20
|
+
with metergraph.context(
|
|
21
|
+
session_id="ticket-123",
|
|
22
|
+
tags={"customer": "acme"},
|
|
23
|
+
):
|
|
24
|
+
with metergraph.trace("ticket-workflow"):
|
|
25
|
+
with metergraph.route("ticket-classifier", unit="answer"):
|
|
26
|
+
model = metergraph.model_for("ticket-classifier", default="gpt-4.1-mini")
|
|
27
|
+
client.chat.completions.create(model=model, messages=[...])
|
|
47
28
|
|
|
48
29
|
# Emit this after the user-visible task resolves. It shares the bounded async
|
|
49
30
|
# transport and contains no prompt or output content.
|
|
@@ -57,6 +38,14 @@ metergraph.record_outcome(
|
|
|
57
38
|
)
|
|
58
39
|
```
|
|
59
40
|
|
|
41
|
+
Use `metergraph.context()` for request or job identity. It follows async work
|
|
42
|
+
created inside the scope and is restored afterward, so concurrent and reused
|
|
43
|
+
workers cannot leak session IDs or tags into one another. The narrower
|
|
44
|
+
`metergraph.session()` and `metergraph.tags()` scopes compose with it.
|
|
45
|
+
`metergraph.set_default_tags()` sets process-wide service metadata. Legacy
|
|
46
|
+
`set_session()` and `set_tags()` calls only update an active Metergraph scope;
|
|
47
|
+
outside one they warn once and do nothing.
|
|
48
|
+
|
|
60
49
|
Vercel's supported Python surface is AI Gateway through the OpenAI or
|
|
61
50
|
Anthropic SDK. Point either client at the public gateway and `wrap()` detects
|
|
62
51
|
it automatically:
|
|
@@ -83,12 +72,44 @@ async, streaming, tool calls, and OpenAI Responses API calls are captured. Use
|
|
|
83
72
|
`metergraph.wrap(client, provider="vercel")` only when a compatible client is
|
|
84
73
|
behind a custom gateway URL that cannot be detected automatically.
|
|
85
74
|
|
|
75
|
+
## OpenTelemetry GenAI export
|
|
76
|
+
|
|
77
|
+
`MetergraphGenAIExporter` is a standard OpenTelemetry span exporter for GenAI
|
|
78
|
+
semantic-convention spans. The currently qualified integration is LiteLLM:
|
|
79
|
+
install the optional integration and configure MeterGraph as LiteLLM's custom
|
|
80
|
+
exporter. Existing LiteLLM call sites remain unchanged and must not also be
|
|
81
|
+
wrapped.
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
python -m pip install 'metergraph[otel]' 'litellm[proxy]>=1.96.2,<2'
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
import litellm
|
|
89
|
+
from litellm.integrations.opentelemetry import OpenTelemetry, OpenTelemetryConfig
|
|
90
|
+
from metergraph.opentelemetry import MetergraphGenAIExporter
|
|
91
|
+
|
|
92
|
+
litellm.callbacks.append(OpenTelemetry(OpenTelemetryConfig(
|
|
93
|
+
exporter=MetergraphGenAIExporter(),
|
|
94
|
+
capture_message_content="SPAN_ONLY",
|
|
95
|
+
)))
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The exporter preserves OpenTelemetry trace identity, model/provider metadata,
|
|
99
|
+
token usage, latency, system instructions, ordered messages, and text output.
|
|
100
|
+
Message content is explicitly enabled because it may be sensitive. Text parts
|
|
101
|
+
are retained and replayable in the current POC pipeline. Calls containing other
|
|
102
|
+
part types retain their model, usage, timing, and status metadata, but those
|
|
103
|
+
parts are not replayable yet. See the runnable
|
|
104
|
+
[`python-litellm-otel` example](../examples/python-litellm-otel/).
|
|
105
|
+
|
|
86
106
|
Configuration:
|
|
87
107
|
|
|
88
108
|
- `METERGRAPH_APP_TOKEN` — required bearer token
|
|
89
109
|
- `METERGRAPH_INGEST_URL` — optional override; defaults to the hosted HTTPS endpoint
|
|
90
110
|
- `METERGRAPH_REPOSITORY` — optional `owner/repository` identity; used by [MeterGraph Bot](https://github.com/apps/metergraph)
|
|
91
111
|
- `METERGRAPH_CAPTURE_TEXT=0` — opt out of content capture globally
|
|
112
|
+
- `METERGRAPH_TEXT_MAX_BYTES` — per-field content limit; defaults to 1 MiB
|
|
92
113
|
- `METERGRAPH_DISABLED=1` — process kill switch
|
|
93
114
|
- `METERGRAPH_QUEUE_SIZE`, `METERGRAPH_BATCH_SIZE`, `METERGRAPH_FLUSH_SECONDS`
|
|
94
115
|
|
|
@@ -110,11 +131,13 @@ identity, it warns once and continues capture without repository attribution.
|
|
|
110
131
|
|
|
111
132
|
Delivery is bounded and off the request path. Queue overflow or a collector
|
|
112
133
|
outage drops capture and increments internal counters; it never changes the
|
|
113
|
-
provider call. Each wire batch is bounded to
|
|
134
|
+
provider call. Each wire batch is bounded to 4 MiB after optional gzip.
|
|
114
135
|
By default, Metergraph captures the scrubbed provider request and a normalized
|
|
115
136
|
response envelope, including assistant content and tool calls. Provider
|
|
116
137
|
credentials and transport headers are removed. Request and response are each
|
|
117
|
-
limited to
|
|
138
|
+
limited to 1 MiB of UTF-8 by default with an explicit truncation marker. Set
|
|
139
|
+
`METERGRAPH_TEXT_MAX_BYTES` or initialize with `text_max_bytes=...` to raise
|
|
140
|
+
the per-field limit for larger prompts and responses.
|
|
118
141
|
`capture_text=False` on `route()` or `trace()` overrides the global content
|
|
119
142
|
policy for a sensitive operation. The equivalent initialization option is
|
|
120
143
|
`metergraph.init(capture_text=False)`. The public open-source server continues
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "metergraph"
|
|
3
|
-
version = "0.6.
|
|
3
|
+
version = "0.6.3"
|
|
4
4
|
description = "Fire-and-forget LLM spend capture for Metergraph"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
@@ -20,7 +20,8 @@ Repository = "https://github.com/PioneerSquareLabs/metergraphsdk"
|
|
|
20
20
|
Issues = "https://github.com/PioneerSquareLabs/metergraphsdk/issues"
|
|
21
21
|
|
|
22
22
|
[project.optional-dependencies]
|
|
23
|
-
|
|
23
|
+
otel = ["opentelemetry-sdk>=1.30"]
|
|
24
|
+
dev = ["build>=1", "pytest>=8", "openai>=2.50.0,<3", "anthropic>=0.40,<1", "google-genai>=1", "opentelemetry-sdk>=1.30"]
|
|
24
25
|
|
|
25
26
|
[build-system]
|
|
26
27
|
requires = ["setuptools>=68"]
|
|
@@ -10,10 +10,21 @@ import uuid
|
|
|
10
10
|
from datetime import datetime, timezone
|
|
11
11
|
from typing import Any, Callable
|
|
12
12
|
|
|
13
|
-
from ._capture import Options, Runtime, set_runtime
|
|
13
|
+
from ._capture import DEFAULT_TEXT_MAX_BYTES, Options, Runtime, set_runtime
|
|
14
14
|
from ._capture import wrap as _wrap
|
|
15
15
|
from ._config import ConfigPoller
|
|
16
|
-
from ._context import
|
|
16
|
+
from ._context import (
|
|
17
|
+
context,
|
|
18
|
+
route,
|
|
19
|
+
session,
|
|
20
|
+
set_default_tags,
|
|
21
|
+
set_session,
|
|
22
|
+
set_tags,
|
|
23
|
+
snapshot,
|
|
24
|
+
tags,
|
|
25
|
+
trace,
|
|
26
|
+
wrap_executor,
|
|
27
|
+
)
|
|
17
28
|
from ._batch_first import (
|
|
18
29
|
BatchFirstIneligibleError,
|
|
19
30
|
BatchFirstMetadata,
|
|
@@ -69,6 +80,7 @@ def init(
|
|
|
69
80
|
skip_frames: list[str] | None = None,
|
|
70
81
|
environment: str | None = None,
|
|
71
82
|
disabled: bool | None = None,
|
|
83
|
+
text_max_bytes: int | None = None,
|
|
72
84
|
) -> None:
|
|
73
85
|
"""Initialize capture. This function is idempotent and never raises."""
|
|
74
86
|
global _initialized, _warned_no_token, _warned_no_repository
|
|
@@ -136,15 +148,14 @@ def init(
|
|
|
136
148
|
repo_root=repo_config.repo_root if repo_config is not None else None,
|
|
137
149
|
skip_frames=tuple(skip_frames or ()),
|
|
138
150
|
environment=environment or os.getenv("METERGRAPH_ENV"),
|
|
139
|
-
text_max_bytes=
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
),
|
|
151
|
+
text_max_bytes=max(
|
|
152
|
+
1,
|
|
153
|
+
int(
|
|
154
|
+
text_max_bytes
|
|
155
|
+
if text_max_bytes is not None
|
|
156
|
+
else os.getenv(
|
|
157
|
+
"METERGRAPH_TEXT_MAX_BYTES", str(DEFAULT_TEXT_MAX_BYTES)
|
|
158
|
+
)
|
|
148
159
|
),
|
|
149
160
|
),
|
|
150
161
|
)
|
|
@@ -290,14 +301,18 @@ __all__ = [
|
|
|
290
301
|
"BatchFirstResult",
|
|
291
302
|
"LateBatchInfo",
|
|
292
303
|
"batch_first",
|
|
304
|
+
"context",
|
|
293
305
|
"flush",
|
|
294
306
|
"init",
|
|
295
307
|
"model_for",
|
|
296
308
|
"record_outcome",
|
|
297
309
|
"route",
|
|
310
|
+
"session",
|
|
311
|
+
"set_default_tags",
|
|
298
312
|
"set_session",
|
|
299
313
|
"set_tags",
|
|
300
314
|
"shutdown",
|
|
315
|
+
"tags",
|
|
301
316
|
"track",
|
|
302
317
|
"trace",
|
|
303
318
|
"wrap",
|
|
@@ -24,6 +24,7 @@ from ._version import SDK_VERSION
|
|
|
24
24
|
|
|
25
25
|
|
|
26
26
|
log = logging.getLogger("metergraph")
|
|
27
|
+
DEFAULT_TEXT_MAX_BYTES = 1024 * 1024
|
|
27
28
|
|
|
28
29
|
|
|
29
30
|
def _get(value: Any, name: str, default: Any = None) -> Any:
|
|
@@ -254,6 +255,48 @@ def _stop_reason(response: Any) -> str | None:
|
|
|
254
255
|
return str(reason) if reason is not None else None
|
|
255
256
|
|
|
256
257
|
|
|
258
|
+
def _normalize_finish_reason(value: str) -> str:
|
|
259
|
+
normalized = "-".join(value.strip().lower().replace("_", "-").split())
|
|
260
|
+
if normalized in {"stop", "end-turn", "stop-sequence", "completed", "succeeded"}:
|
|
261
|
+
return "stop"
|
|
262
|
+
if normalized in {"length", "max-tokens", "max-output-tokens"}:
|
|
263
|
+
return "length"
|
|
264
|
+
if normalized in {"content-filter", "safety", "blocked"}:
|
|
265
|
+
return "content-filter"
|
|
266
|
+
if normalized in {"tool-calls", "tool-use", "function-call"}:
|
|
267
|
+
return "tool-calls"
|
|
268
|
+
if normalized in {"error", "failed"}:
|
|
269
|
+
return "error"
|
|
270
|
+
if normalized in {"other", "unknown"}:
|
|
271
|
+
return "other"
|
|
272
|
+
return normalized
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _finish_reason_details(response: Any) -> tuple[str | None, str | None]:
|
|
276
|
+
response_status = _get(response, "status")
|
|
277
|
+
incomplete_reason = (
|
|
278
|
+
_get(_get(response, "incomplete_details"), "reason")
|
|
279
|
+
if response_status == "incomplete"
|
|
280
|
+
else None
|
|
281
|
+
)
|
|
282
|
+
value = (
|
|
283
|
+
_get(response, "stop_reason")
|
|
284
|
+
or incomplete_reason
|
|
285
|
+
or response_status
|
|
286
|
+
or _get(response, "finishReason")
|
|
287
|
+
or _get(response, "finish_reason")
|
|
288
|
+
or _get(_first(_get(response, "choices")), "finish_reason")
|
|
289
|
+
)
|
|
290
|
+
unified = _get(value, "unified")
|
|
291
|
+
raw_value = _get(value, "raw") or (value if unified is None else None)
|
|
292
|
+
source = unified if unified is not None else raw_value
|
|
293
|
+
if source is None:
|
|
294
|
+
return None, None
|
|
295
|
+
finish_reason = _normalize_finish_reason(str(source))
|
|
296
|
+
raw = str(raw_value) if raw_value is not None else None
|
|
297
|
+
return finish_reason, raw if raw != finish_reason else None
|
|
298
|
+
|
|
299
|
+
|
|
257
300
|
def _request_id(response: Any) -> str | None:
|
|
258
301
|
value = (
|
|
259
302
|
_get(response, "_request_id")
|
|
@@ -597,7 +640,7 @@ class Options:
|
|
|
597
640
|
repo_root: str | None = None
|
|
598
641
|
skip_frames: tuple[str, ...] = ()
|
|
599
642
|
environment: str | None = None
|
|
600
|
-
text_max_bytes: int =
|
|
643
|
+
text_max_bytes: int = DEFAULT_TEXT_MAX_BYTES
|
|
601
644
|
|
|
602
645
|
|
|
603
646
|
class Runtime:
|
|
@@ -740,6 +783,12 @@ class CallState:
|
|
|
740
783
|
effective_status = status or (
|
|
741
784
|
"error" if error else _stop_reason(response) or "success"
|
|
742
785
|
)
|
|
786
|
+
finish_reason, finish_reason_raw = _finish_reason_details(response)
|
|
787
|
+
status_code = (
|
|
788
|
+
"error"
|
|
789
|
+
if error or status == "error" or finish_reason == "error"
|
|
790
|
+
else "unset"
|
|
791
|
+
)
|
|
743
792
|
response_json, response_truncated = self.runtime._text(
|
|
744
793
|
json.dumps(
|
|
745
794
|
_response_envelope(
|
|
@@ -764,6 +813,9 @@ class CallState:
|
|
|
764
813
|
**_usage(response),
|
|
765
814
|
"latency_ms": round((time.perf_counter() - self.started) * 1000),
|
|
766
815
|
"status": effective_status,
|
|
816
|
+
"status_code": status_code,
|
|
817
|
+
"finish_reason": finish_reason,
|
|
818
|
+
"finish_reason_raw": finish_reason_raw,
|
|
767
819
|
"session_id": self.context.session_id,
|
|
768
820
|
"conversation_id": self.context.session_id,
|
|
769
821
|
"trace_id": self.trace_id,
|
|
@@ -792,7 +844,7 @@ class CallState:
|
|
|
792
844
|
"frames_json": self.frames,
|
|
793
845
|
"tags": dict(self.context.tags),
|
|
794
846
|
"environment": self.runtime.options.environment,
|
|
795
|
-
"error":
|
|
847
|
+
"error": status_code == "error",
|
|
796
848
|
"error_type": type(error).__name__ if error else None,
|
|
797
849
|
"sdk": "python",
|
|
798
850
|
"sdk_version": SDK_VERSION,
|
|
@@ -806,14 +858,22 @@ class CallState:
|
|
|
806
858
|
|
|
807
859
|
class _StreamState:
|
|
808
860
|
def __init__(self, stream: Any, call: CallState) -> None:
|
|
861
|
+
self.manager = stream
|
|
809
862
|
self.stream = stream
|
|
810
863
|
self.call = call
|
|
811
864
|
self.iterator = None
|
|
865
|
+
self.entered = False
|
|
812
866
|
self.last = None
|
|
813
867
|
self.parts: list[str] = []
|
|
814
868
|
self.chunks: list[Any] = []
|
|
815
869
|
self.ttft_ms: int | None = None
|
|
816
870
|
|
|
871
|
+
def use_entered_stream(self, stream: Any) -> None:
|
|
872
|
+
if stream is not None:
|
|
873
|
+
self.stream = stream
|
|
874
|
+
self.iterator = None
|
|
875
|
+
self.entered = True
|
|
876
|
+
|
|
817
877
|
def chunk(self, value: Any) -> Any:
|
|
818
878
|
self.last = value
|
|
819
879
|
self.chunks.append(value)
|
|
@@ -903,17 +963,21 @@ class SyncStream:
|
|
|
903
963
|
raise
|
|
904
964
|
|
|
905
965
|
def __enter__(self) -> "SyncStream":
|
|
906
|
-
enter = getattr(self._state.
|
|
966
|
+
enter = getattr(self._state.manager, "__enter__", None)
|
|
907
967
|
if enter:
|
|
908
|
-
|
|
968
|
+
try:
|
|
969
|
+
self._state.use_entered_stream(enter())
|
|
970
|
+
except BaseException as exc:
|
|
971
|
+
self._state.finish(error=exc)
|
|
972
|
+
raise
|
|
909
973
|
return self
|
|
910
974
|
|
|
911
975
|
def __exit__(self, exc_type, exc, tb) -> Any:
|
|
912
976
|
if exc:
|
|
913
977
|
self._state.finish(error=exc)
|
|
914
978
|
elif not self._state.call.done:
|
|
915
|
-
self._state.finish(status="abandoned")
|
|
916
|
-
exit_fn = getattr(self._state.
|
|
979
|
+
self._state.finish(status=None if self._state.entered else "abandoned")
|
|
980
|
+
exit_fn = getattr(self._state.manager, "__exit__", None)
|
|
917
981
|
return exit_fn(exc_type, exc, tb) if exit_fn else None
|
|
918
982
|
|
|
919
983
|
def close(self) -> None:
|
|
@@ -960,17 +1024,23 @@ class AsyncStream:
|
|
|
960
1024
|
raise
|
|
961
1025
|
|
|
962
1026
|
async def __aenter__(self) -> "AsyncStream":
|
|
963
|
-
enter = getattr(self._state.
|
|
1027
|
+
enter = getattr(self._state.manager, "__aenter__", None)
|
|
964
1028
|
if enter:
|
|
965
|
-
|
|
1029
|
+
try:
|
|
1030
|
+
self._state.use_entered_stream(await enter())
|
|
1031
|
+
except BaseException as exc:
|
|
1032
|
+
await self._state.finish_async(error=exc)
|
|
1033
|
+
raise
|
|
966
1034
|
return self
|
|
967
1035
|
|
|
968
1036
|
async def __aexit__(self, exc_type, exc, tb) -> Any:
|
|
969
1037
|
if exc:
|
|
970
1038
|
await self._state.finish_async(error=exc)
|
|
971
1039
|
elif not self._state.call.done:
|
|
972
|
-
await self._state.finish_async(
|
|
973
|
-
|
|
1040
|
+
await self._state.finish_async(
|
|
1041
|
+
status=None if self._state.entered else "abandoned"
|
|
1042
|
+
)
|
|
1043
|
+
exit_fn = getattr(self._state.manager, "__aexit__", None)
|
|
974
1044
|
return await exit_fn(exc_type, exc, tb) if exit_fn else None
|
|
975
1045
|
|
|
976
1046
|
async def aclose(self) -> None:
|
|
@@ -1004,6 +1074,11 @@ def set_runtime(runtime: Runtime | None) -> None:
|
|
|
1004
1074
|
_runtime = runtime
|
|
1005
1075
|
|
|
1006
1076
|
|
|
1077
|
+
def _get_runtime() -> Runtime | None:
|
|
1078
|
+
"""Return the active runtime to internal capture integrations."""
|
|
1079
|
+
return _runtime
|
|
1080
|
+
|
|
1081
|
+
|
|
1007
1082
|
def _request(args: tuple, kwargs: dict) -> dict[str, Any]:
|
|
1008
1083
|
request: dict[str, Any] = {}
|
|
1009
1084
|
if args and isinstance(args[0], Mapping):
|
|
@@ -1318,9 +1393,15 @@ def _finish_or_stream(
|
|
|
1318
1393
|
result: Any, call: CallState, endpoint: str, request: Mapping[str, Any]
|
|
1319
1394
|
):
|
|
1320
1395
|
is_stream = endpoint.endswith(".stream") or bool(request.get("stream"))
|
|
1321
|
-
if is_stream and
|
|
1396
|
+
if is_stream and (
|
|
1397
|
+
hasattr(result, "__aiter__")
|
|
1398
|
+
or (hasattr(result, "__aenter__") and hasattr(result, "__aexit__"))
|
|
1399
|
+
):
|
|
1322
1400
|
return AsyncStream(result, call)
|
|
1323
|
-
if is_stream and
|
|
1401
|
+
if is_stream and (
|
|
1402
|
+
hasattr(result, "__iter__")
|
|
1403
|
+
or (hasattr(result, "__enter__") and hasattr(result, "__exit__"))
|
|
1404
|
+
):
|
|
1324
1405
|
return SyncStream(result, call)
|
|
1325
1406
|
call.finish(result)
|
|
1326
1407
|
return result
|