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.
Files changed (26) hide show
  1. {metergraph-0.6.0 → metergraph-0.6.3}/PKG-INFO +58 -10
  2. metergraph-0.6.0/src/metergraph.egg-info/PKG-INFO → metergraph-0.6.3/README.md +52 -29
  3. {metergraph-0.6.0 → metergraph-0.6.3}/pyproject.toml +3 -2
  4. {metergraph-0.6.0 → metergraph-0.6.3}/src/metergraph/__init__.py +26 -11
  5. {metergraph-0.6.0 → metergraph-0.6.3}/src/metergraph/_capture.py +93 -12
  6. {metergraph-0.6.0 → metergraph-0.6.3}/src/metergraph/_context.py +117 -11
  7. metergraph-0.6.3/src/metergraph/_repo_config.py +63 -0
  8. {metergraph-0.6.0 → metergraph-0.6.3}/src/metergraph/_transport.py +1 -1
  9. {metergraph-0.6.0 → metergraph-0.6.3}/src/metergraph/_version.py +1 -1
  10. metergraph-0.6.3/src/metergraph/opentelemetry.py +221 -0
  11. metergraph-0.6.0/README.md → metergraph-0.6.3/src/metergraph.egg-info/PKG-INFO +77 -7
  12. {metergraph-0.6.0 → metergraph-0.6.3}/src/metergraph.egg-info/SOURCES.txt +1 -0
  13. metergraph-0.6.3/src/metergraph.egg-info/requires.txt +11 -0
  14. metergraph-0.6.0/src/metergraph/_repo_config.py +0 -165
  15. metergraph-0.6.0/src/metergraph.egg-info/requires.txt +0 -7
  16. {metergraph-0.6.0 → metergraph-0.6.3}/MANIFEST.in +0 -0
  17. {metergraph-0.6.0 → metergraph-0.6.3}/setup.cfg +0 -0
  18. {metergraph-0.6.0 → metergraph-0.6.3}/src/metergraph/_batch_first.py +0 -0
  19. {metergraph-0.6.0 → metergraph-0.6.3}/src/metergraph/_config.py +0 -0
  20. {metergraph-0.6.0 → metergraph-0.6.3}/src/metergraph/_failure_log.py +0 -0
  21. {metergraph-0.6.0 → metergraph-0.6.3}/src/metergraph/_provider_batch.py +0 -0
  22. {metergraph-0.6.0 → metergraph-0.6.3}/src/metergraph/_session.py +0 -0
  23. {metergraph-0.6.0 → metergraph-0.6.3}/src/metergraph/_template.py +0 -0
  24. {metergraph-0.6.0 → metergraph-0.6.3}/src/metergraph/_track.py +0 -0
  25. {metergraph-0.6.0 → metergraph-0.6.3}/src/metergraph.egg-info/dependency_links.txt +0 -0
  26. {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.0
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>=2.50.0; extra == "dev"
20
- Requires-Dist: anthropic>=0.40; extra == "dev"
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.trace("ticket-workflow"):
44
- with metergraph.route("ticket-classifier", unit="answer"):
45
- model = metergraph.model_for("ticket-classifier", default="gpt-4.1-mini")
46
- client.chat.completions.create(model=model, messages=[...])
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 512 KiB after optional gzip.
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 100 KiB of UTF-8 with an explicit truncation marker.
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.trace("ticket-workflow"):
44
- with metergraph.route("ticket-classifier", unit="answer"):
45
- model = metergraph.model_for("ticket-classifier", default="gpt-4.1-mini")
46
- client.chat.completions.create(model=model, messages=[...])
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 512 KiB after optional gzip.
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 100 KiB of UTF-8 with an explicit truncation marker.
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.0"
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
- dev = ["build>=1", "pytest>=8", "openai>=2.50.0", "anthropic>=0.40", "google-genai>=1"]
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 route, set_session, set_tags, snapshot, trace, wrap_executor
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=min(
140
- 100 * 1024,
141
- max(
142
- 1,
143
- int(
144
- os.getenv(
145
- "METERGRAPH_TEXT_MAX_BYTES", str(100 * 1024)
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 = 100 * 1024
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": bool(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.stream, "__enter__", None)
966
+ enter = getattr(self._state.manager, "__enter__", None)
907
967
  if enter:
908
- enter()
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.stream, "__exit__", None)
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.stream, "__aenter__", None)
1027
+ enter = getattr(self._state.manager, "__aenter__", None)
964
1028
  if enter:
965
- await enter()
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(status="abandoned")
973
- exit_fn = getattr(self._state.stream, "__aexit__", None)
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 hasattr(result, "__aiter__"):
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 hasattr(result, "__iter__"):
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