metergraphrelay 0.7.2__tar.gz → 0.7.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 (47) hide show
  1. {metergraphrelay-0.7.2/src/metergraphrelay.egg-info → metergraphrelay-0.7.3}/PKG-INFO +2 -2
  2. {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/README.md +1 -1
  3. {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/pyproject.toml +2 -2
  4. metergraphrelay-0.7.3/src/metergraphrelay/__init__.py +1 -0
  5. {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/src/metergraphrelay/cli.py +19 -78
  6. metergraphrelay-0.7.3/src/metergraphrelay/finish_reason.py +107 -0
  7. metergraphrelay-0.7.3/src/metergraphrelay/http_limits.py +36 -0
  8. {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/src/metergraphrelay/import_identity.py +1 -0
  9. {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/src/metergraphrelay/portkey_sync.py +23 -1
  10. {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/src/metergraphrelay/provider_sync.py +23 -1
  11. {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/src/metergraphrelay/providers/braintrust.py +23 -2
  12. {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/src/metergraphrelay/providers/langfuse.py +23 -2
  13. {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/src/metergraphrelay/providers/langsmith.py +23 -2
  14. {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/src/metergraphrelay/providers/phoenix.py +23 -2
  15. {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/src/metergraphrelay/providers/portkey.py +5 -1
  16. {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/src/metergraphrelay/providers/portkey_export.py +30 -3
  17. {metergraphrelay-0.7.2 → metergraphrelay-0.7.3/src/metergraphrelay.egg-info}/PKG-INFO +2 -2
  18. {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/src/metergraphrelay.egg-info/SOURCES.txt +3 -0
  19. {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/tests/test_cli.py +21 -37
  20. metergraphrelay-0.7.3/tests/test_finish_reason.py +29 -0
  21. {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/tests/test_portkey_sync.py +2 -0
  22. metergraphrelay-0.7.2/src/metergraphrelay/__init__.py +0 -1
  23. {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/LICENSE +0 -0
  24. {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/setup.cfg +0 -0
  25. {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/src/metergraphrelay/billing_evidence.py +0 -0
  26. {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/src/metergraphrelay/capture_contract.py +0 -0
  27. {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/src/metergraphrelay/config.py +0 -0
  28. {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/src/metergraphrelay/demo.py +0 -0
  29. {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/src/metergraphrelay/metergraph_sync.py +0 -0
  30. {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/src/metergraphrelay/providers/__init__.py +0 -0
  31. {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/src/metergraphrelay/providers/openai.py +0 -0
  32. {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/src/metergraphrelay/push.py +0 -0
  33. {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/src/metergraphrelay/py.typed +0 -0
  34. {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/src/metergraphrelay/sync_core.py +0 -0
  35. {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/src/metergraphrelay/window.py +0 -0
  36. {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/src/metergraphrelay.egg-info/dependency_links.txt +0 -0
  37. {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/src/metergraphrelay.egg-info/entry_points.txt +0 -0
  38. {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/src/metergraphrelay.egg-info/requires.txt +0 -0
  39. {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/src/metergraphrelay.egg-info/top_level.txt +0 -0
  40. {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/tests/test_billing_evidence.py +0 -0
  41. {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/tests/test_capture_contract.py +0 -0
  42. {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/tests/test_config.py +0 -0
  43. {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/tests/test_demo.py +0 -0
  44. {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/tests/test_metergraph_sync.py +0 -0
  45. {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/tests/test_provider_sync.py +0 -0
  46. {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/tests/test_push.py +0 -0
  47. {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/tests/test_window.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: metergraphrelay
3
- Version: 0.7.2
3
+ Version: 0.7.3
4
4
  Summary: Move LLM trace data between systems: pull from providers, push to metergraph.
5
5
  Author: Vasiliy Radostev
6
6
  License-Expression: MIT
@@ -309,7 +309,7 @@ but LLM spans is ever imported, but `--name` has no effect there.
309
309
 
310
310
  **Setup:** nothing, for a local Phoenix. The default base URL is
311
311
  `http://localhost:6006`. For a remote or authenticated Phoenix, set these
312
- in `.env` (or pass `--base-url` / `--phoenix-api-key` per-command):
312
+ in `.env` (and pass `--base-url` only when selecting a different endpoint):
313
313
 
314
314
  PHOENIX_BASE_URL=https://phoenix.example.com
315
315
  PHOENIX_API_KEY=...
@@ -286,7 +286,7 @@ but LLM spans is ever imported, but `--name` has no effect there.
286
286
 
287
287
  **Setup:** nothing, for a local Phoenix. The default base URL is
288
288
  `http://localhost:6006`. For a remote or authenticated Phoenix, set these
289
- in `.env` (or pass `--base-url` / `--phoenix-api-key` per-command):
289
+ in `.env` (and pass `--base-url` only when selecting a different endpoint):
290
290
 
291
291
  PHOENIX_BASE_URL=https://phoenix.example.com
292
292
  PHOENIX_API_KEY=...
@@ -1,10 +1,10 @@
1
1
  [build-system]
2
- requires = ["setuptools>=77"]
2
+ requires = ["setuptools==80.9.0"]
3
3
  build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "metergraphrelay"
7
- version = "0.7.2"
7
+ version = "0.7.3"
8
8
  description = "Move LLM trace data between systems: pull from providers, push to metergraph."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1 @@
1
+ __version__ = "0.7.3"
@@ -12,7 +12,7 @@ from openai import OpenAI
12
12
  from .config import ConfigError, require_credentials
13
13
  from .demo import run_demo
14
14
  from .metergraph_sync import MeterGraphSyncClient, MeterGraphSyncError
15
- from .portkey_sync import run_portkey_sync
15
+ from .portkey_sync import _make_content_blind, run_portkey_sync
16
16
  from .provider_sync import UNBOUNDED_COUNT, run_pull_sync
17
17
  from .providers.braintrust import (
18
18
  DEFAULT_BRAINTRUST_URL,
@@ -159,24 +159,6 @@ def build_parser() -> argparse.ArgumentParser:
159
159
  default=".env",
160
160
  help="Path to a .env file to load credentials from. (default: .env)",
161
161
  )
162
- pull_langfuse_parser.add_argument(
163
- "--langfuse-public-key",
164
- default=None,
165
- metavar="KEY",
166
- help=(
167
- "Langfuse public key (Basic Auth username). Overrides "
168
- "$LANGFUSE_PUBLIC_KEY / .env if given; env/.env is the preferred path."
169
- ),
170
- )
171
- pull_langfuse_parser.add_argument(
172
- "--langfuse-secret-key",
173
- default=None,
174
- metavar="KEY",
175
- help=(
176
- "Langfuse secret key (Basic Auth password). Overrides "
177
- "$LANGFUSE_SECRET_KEY / .env if given; env/.env is the preferred path."
178
- ),
179
- )
180
162
 
181
163
  pull_braintrust_parser = pull_subparsers.add_parser(
182
164
  "braintrust",
@@ -262,15 +244,6 @@ def build_parser() -> argparse.ArgumentParser:
262
244
  default=".env",
263
245
  help="Path to a .env file to load credentials from. (default: .env)",
264
246
  )
265
- pull_braintrust_parser.add_argument(
266
- "--braintrust-api-key",
267
- default=None,
268
- metavar="KEY",
269
- help=(
270
- "Braintrust API key (Bearer token). Overrides $BRAINTRUST_API_KEY "
271
- "/ .env if given; env/.env is the preferred path."
272
- ),
273
- )
274
247
 
275
248
  pull_langsmith_parser = pull_subparsers.add_parser(
276
249
  "langsmith",
@@ -350,13 +323,6 @@ def build_parser() -> argparse.ArgumentParser:
350
323
  "--env-file", default=".env",
351
324
  help="Path to a .env file to load credentials from. (default: .env)",
352
325
  )
353
- pull_langsmith_parser.add_argument(
354
- "--langsmith-api-key", default=None, metavar="KEY",
355
- help=(
356
- "LangSmith API key. Overrides $LANGSMITH_API_KEY / .env if given; "
357
- "env/.env is the preferred path."
358
- ),
359
- )
360
326
 
361
327
  pull_phoenix_parser = pull_subparsers.add_parser(
362
328
  "phoenix",
@@ -451,16 +417,6 @@ def build_parser() -> argparse.ArgumentParser:
451
417
  default=".env",
452
418
  help="Path to a .env file to load settings from. (default: .env)",
453
419
  )
454
- pull_phoenix_parser.add_argument(
455
- "--phoenix-api-key",
456
- default=None,
457
- metavar="KEY",
458
- help=(
459
- "Phoenix API key (Bearer token), only needed when the server has "
460
- "authentication enabled. Overrides $PHOENIX_API_KEY / .env if "
461
- "given. (default: none)"
462
- ),
463
- )
464
420
 
465
421
  sync_parser = subparsers.add_parser(
466
422
  "sync",
@@ -502,8 +458,8 @@ def build_parser() -> argparse.ArgumentParser:
502
458
  "--initial-since seeds only the first run; --max-window-seconds "
503
459
  "caps the window at 3600. One Portkey workspace per metergraph app "
504
460
  "(MVP). A 'busy' lease or a 'caught up' server exit 0 as clean "
505
- "no-ops. In both modes request and response content is uploaded to "
506
- "MeterGraph with no opt-out."
461
+ "no-ops. Scheduled imports omit request and response content unless "
462
+ "--include-content is explicitly passed."
507
463
  ),
508
464
  formatter_class=argparse.RawDescriptionHelpFormatter,
509
465
  help=(
@@ -565,6 +521,11 @@ def build_parser() -> argparse.ArgumentParser:
565
521
  "(both modes) and PORTKEY_API_KEY (API mode). (default: .env)"
566
522
  ),
567
523
  )
524
+ sync_portkey_parser.add_argument(
525
+ "--include-content",
526
+ action="store_true",
527
+ help="Include request and response content in the upload.",
528
+ )
568
529
 
569
530
  # --- sync langfuse / braintrust / phoenix: server-coordinated cron mode ---
570
531
  # One shape for the three cursor-paged providers. The server picks the
@@ -613,14 +574,6 @@ def build_parser() -> argparse.ArgumentParser:
613
574
  f"{DEFAULT_LANGFUSE_HOST})"
614
575
  ),
615
576
  )
616
- sync_langfuse_parser.add_argument(
617
- "--langfuse-public-key", default=None, metavar="KEY",
618
- help="Langfuse public key. Overrides $LANGFUSE_PUBLIC_KEY / .env if given.",
619
- )
620
- sync_langfuse_parser.add_argument(
621
- "--langfuse-secret-key", default=None, metavar="KEY",
622
- help="Langfuse secret key. Overrides $LANGFUSE_SECRET_KEY / .env if given.",
623
- )
624
577
 
625
578
  sync_braintrust_parser = sync_subparsers.add_parser(
626
579
  "braintrust",
@@ -653,10 +606,6 @@ def build_parser() -> argparse.ArgumentParser:
653
606
  "else the US data plane)"
654
607
  ),
655
608
  )
656
- sync_braintrust_parser.add_argument(
657
- "--braintrust-api-key", default=None, metavar="KEY",
658
- help="Braintrust API key. Overrides $BRAINTRUST_API_KEY / .env if given.",
659
- )
660
609
 
661
610
  sync_phoenix_parser = sync_subparsers.add_parser(
662
611
  "phoenix",
@@ -694,13 +643,6 @@ def build_parser() -> argparse.ArgumentParser:
694
643
  f"{DEFAULT_PHOENIX_URL})"
695
644
  ),
696
645
  )
697
- sync_phoenix_parser.add_argument(
698
- "--phoenix-api-key", default=None, metavar="KEY",
699
- help=(
700
- "Phoenix API key, only when the server has authentication enabled. "
701
- "Overrides $PHOENIX_API_KEY / .env if given."
702
- ),
703
- )
704
646
 
705
647
  sync_langsmith_parser = sync_subparsers.add_parser(
706
648
  "langsmith",
@@ -742,10 +684,6 @@ def build_parser() -> argparse.ArgumentParser:
742
684
  f"$LANGSMITH_BASE_URL if set, else {DEFAULT_LANGSMITH_URL})"
743
685
  ),
744
686
  )
745
- sync_langsmith_parser.add_argument(
746
- "--langsmith-api-key", default=None, metavar="KEY",
747
- help="LangSmith API key. Overrides $LANGSMITH_API_KEY / .env if given.",
748
- )
749
687
 
750
688
  demo_parser = subparsers.add_parser(
751
689
  "demo", help="Run 1-2 demo conversations with store=True"
@@ -801,6 +739,11 @@ def _add_sync_common(parser: argparse.ArgumentParser, *, scope_help: str) -> Non
801
739
  default=".env",
802
740
  help="Path to a .env file to load credentials from. (default: .env)",
803
741
  )
742
+ parser.add_argument(
743
+ "--include-content",
744
+ action="store_true",
745
+ help="Explicitly opt this scheduled import into prompt and completion content.",
746
+ )
804
747
 
805
748
 
806
749
  def _config_error(exc: ConfigError) -> int:
@@ -827,8 +770,6 @@ def _resolve_langfuse_credentials(args: argparse.Namespace) -> tuple[str, str]:
827
770
  # settings (e.g. LANGFUSE_BASE_URL) may still live only in that file, not
828
771
  # the real process environment, and must still resolve.
829
772
  load_dotenv(args.env_file, override=True)
830
- if args.langfuse_public_key and args.langfuse_secret_key:
831
- return args.langfuse_public_key, args.langfuse_secret_key
832
773
  creds = require_credentials("langfuse", args.env_file)
833
774
  return creds["LANGFUSE_PUBLIC_KEY"], creds["LANGFUSE_SECRET_KEY"]
834
775
 
@@ -839,8 +780,6 @@ def _resolve_braintrust_credential(args: argparse.Namespace) -> str:
839
780
  # BRAINTRUST_BASE_URL) may live only in that file, not the real process
840
781
  # environment, and must still resolve.
841
782
  load_dotenv(args.env_file, override=True)
842
- if args.braintrust_api_key:
843
- return args.braintrust_api_key
844
783
  return require_credentials("braintrust", args.env_file)["BRAINTRUST_API_KEY"]
845
784
 
846
785
 
@@ -855,8 +794,6 @@ def _resolve_langsmith_credential(args: argparse.Namespace) -> str:
855
794
  # Load the selected --env-file unconditionally, even when the flag is
856
795
  # given, so optional settings such as LANGSMITH_ENDPOINT are read too.
857
796
  load_dotenv(args.env_file, override=True)
858
- if args.langsmith_api_key:
859
- return args.langsmith_api_key
860
797
  return require_credentials("langsmith", args.env_file)["LANGSMITH_API_KEY"]
861
798
 
862
799
 
@@ -911,6 +848,8 @@ def _run_sync_portkey(args: argparse.Namespace) -> int:
911
848
 
912
849
  try:
913
850
  converted, skipped = convert_portkey_export(args.export_file, tmp_path)
851
+ if not args.include_content:
852
+ _make_content_blind(tmp_path)
914
853
  except (OSError, UnicodeDecodeError) as exc:
915
854
  _cleanup_temp_file(tmp_path)
916
855
  return _os_error(exc)
@@ -1016,6 +955,7 @@ def _run_sync_portkey_api(args: argparse.Namespace) -> int:
1016
955
  max_window_seconds=max_window,
1017
956
  push_token=push_creds["METERGRAPH_APP_TOKEN"],
1018
957
  ingest_base_url=ingest_base,
958
+ content_opted_in=args.include_content,
1019
959
  )
1020
960
  except (MeterGraphSyncError, PortkeyExportError, OSError) as exc:
1021
961
  print(f"Error: {exc}", file=sys.stderr)
@@ -1115,6 +1055,7 @@ def _run_sync_pull(args: argparse.Namespace, *, source: str, source_scope: str,
1115
1055
  ingest_base_url=ingest_base,
1116
1056
  provider_errors=provider_errors,
1117
1057
  allow_skipped=args.allow_skipped,
1058
+ content_opted_in=args.include_content,
1118
1059
  )
1119
1060
  except (MeterGraphSyncError, OSError, *provider_errors) as exc:
1120
1061
  print(f"Error: {exc}", file=sys.stderr)
@@ -1224,7 +1165,7 @@ def _run_sync_phoenix(args: argparse.Namespace) -> int:
1224
1165
  except ConfigError as exc:
1225
1166
  return _config_error(exc)
1226
1167
  # require_credentials() has loaded the env file; optional settings follow.
1227
- api_key = args.phoenix_api_key or os.environ.get("PHOENIX_API_KEY") or None
1168
+ api_key = os.environ.get("PHOENIX_API_KEY") or None
1228
1169
  base_url = (
1229
1170
  args.base_url or os.environ.get("PHOENIX_BASE_URL") or DEFAULT_PHOENIX_URL
1230
1171
  )
@@ -1316,7 +1257,7 @@ def _run_pull_phoenix(args: argparse.Namespace) -> int:
1316
1257
  # the .env file is loaded for its optional settings rather than through
1317
1258
  # require_credentials, which would fail on a missing key.
1318
1259
  load_dotenv(args.env_file, override=True)
1319
- api_key = args.phoenix_api_key or os.environ.get("PHOENIX_API_KEY") or None
1260
+ api_key = os.environ.get("PHOENIX_API_KEY") or None
1320
1261
  base_url = (
1321
1262
  args.base_url or os.environ.get("PHOENIX_BASE_URL") or DEFAULT_PHOENIX_URL
1322
1263
  )
@@ -0,0 +1,107 @@
1
+ """Extract provider completion reasons without guessing from token counts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+
8
+ _MAX_REASON_LENGTH = 128
9
+ # LangChain runs nest the reason as outputs.generations[[...]].generation_info
10
+ # or .message.kwargs.response_metadata, six levels below the run.
11
+ _MAX_DEPTH = 8
12
+ _REASON_KEYS = ("stop_reason", "finish_reason", "finishReason")
13
+ _REASON_CONTAINER_KEYS = (
14
+ "incomplete_details",
15
+ "response",
16
+ "output",
17
+ "outputs",
18
+ "metadata",
19
+ "invocation_params",
20
+ "generations",
21
+ "generation_info",
22
+ "message",
23
+ "kwargs",
24
+ "response_metadata",
25
+ "extra",
26
+ )
27
+
28
+
29
+ def _string_reason(value: object) -> str | None:
30
+ if not isinstance(value, str):
31
+ return None
32
+ reason = value.strip()
33
+ if not reason or len(reason) > _MAX_REASON_LENGTH:
34
+ return None
35
+ return reason
36
+
37
+
38
+ def _reason_value(value: object) -> str | None:
39
+ direct = _string_reason(value)
40
+ if direct is not None:
41
+ return direct
42
+ if isinstance(value, (list, tuple)):
43
+ for item in value:
44
+ reason = _reason_value(item)
45
+ if reason is not None:
46
+ return reason
47
+ return None
48
+
49
+
50
+ def _find_reason(value: object, *, depth: int = 0) -> str | None:
51
+ if depth > _MAX_DEPTH:
52
+ return None
53
+ if isinstance(value, (list, tuple)):
54
+ for item in value:
55
+ reason = _find_reason(item, depth=depth + 1)
56
+ if reason is not None:
57
+ return reason
58
+ return None
59
+ if not isinstance(value, Mapping):
60
+ return None
61
+
62
+ for key in _REASON_KEYS + ("finish_reasons", "finishReasons"):
63
+ reason = _reason_value(value.get(key))
64
+ if reason is not None:
65
+ return reason
66
+ incomplete_details = value.get("incomplete_details")
67
+ if isinstance(incomplete_details, Mapping):
68
+ reason = _reason_value(incomplete_details.get("reason"))
69
+ if reason is not None:
70
+ return reason
71
+ for key in ("choices", "candidates") + tuple(
72
+ key for key in _REASON_CONTAINER_KEYS if key != "incomplete_details"
73
+ ):
74
+ reason = _find_reason(value.get(key), depth=depth + 1)
75
+ if reason is not None:
76
+ return reason
77
+ return None
78
+
79
+
80
+ def extract_stop_reason(*values: Any) -> str | None:
81
+ """Return the first known provider reason from documented response shapes."""
82
+ for value in values:
83
+ reason = _reason_value(value) or _find_reason(value)
84
+ if reason is not None:
85
+ return reason
86
+ return None
87
+
88
+
89
+ def extract_phoenix_stop_reason(attributes: Mapping[str, Any]) -> str | None:
90
+ """Read finish reasons from Phoenix's flattened OpenTelemetry attributes."""
91
+ reason = extract_stop_reason(attributes)
92
+ if reason is not None:
93
+ return reason
94
+ for key, value in attributes.items():
95
+ if (
96
+ key.startswith("llm.output_messages.")
97
+ and key.endswith((".message.finish_reason", ".message.stop_reason"))
98
+ ) or key in {
99
+ "gen_ai.response.finish_reason",
100
+ "gen_ai.response.finish_reasons",
101
+ "llm.response.finish_reason",
102
+ "llm.response.stop_reason",
103
+ }:
104
+ reason = _reason_value(value) or _find_reason(value)
105
+ if reason is not None:
106
+ return reason
107
+ return None
@@ -0,0 +1,36 @@
1
+ """Small bounds shared by provider HTTP clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ MAX_JSON_RESPONSE_BYTES = 2 * 1024 * 1024
7
+ READ_CHUNK_SIZE = 64 * 1024
8
+
9
+
10
+ class ResponseTooLarge(ValueError):
11
+ """Raised when a provider response exceeds the JSON body budget."""
12
+
13
+
14
+ def read_bounded(response, *, max_bytes: int = MAX_JSON_RESPONSE_BYTES) -> bytes:
15
+ """Read a response without allowing an untrusted body to fill memory."""
16
+ declared = response.headers.get("Content-Length")
17
+ if declared:
18
+ try:
19
+ declared_bytes = int(declared)
20
+ except (TypeError, ValueError):
21
+ pass
22
+ else:
23
+ if declared_bytes > max_bytes:
24
+ raise ResponseTooLarge(
25
+ f"response exceeds the {max_bytes}-byte JSON body limit"
26
+ )
27
+
28
+ # ``read(n)`` is bounded by the requested byte count for urllib response
29
+ # objects. Reading one byte beyond the budget detects oversized bodies
30
+ # without ever retaining more than the allowed response plus one byte.
31
+ body = response.read(max_bytes + 1)
32
+ if len(body) > max_bytes:
33
+ raise ResponseTooLarge(
34
+ f"response exceeds the {max_bytes}-byte JSON body limit"
35
+ )
36
+ return body
@@ -28,6 +28,7 @@ class ImportIdentityError(ValueError):
28
28
  class ImportContext:
29
29
  source: str
30
30
  source_scope: str
31
+ content_opted_in: bool = False
31
32
 
32
33
 
33
34
  def canonical_import_event_id(raw: Any) -> str:
@@ -26,6 +26,7 @@ boundaries force an unconditional renewal.
26
26
  from __future__ import annotations
27
27
 
28
28
  import os
29
+ import json
29
30
  import sys
30
31
  import tempfile
31
32
  import time
@@ -75,6 +76,7 @@ def run_portkey_sync(
75
76
  max_poll_seconds: float = MAX_POLL_SECONDS,
76
77
  renew_interval_seconds: float = RENEW_INTERVAL_SECONDS,
77
78
  push: Callable[..., tuple[int, int]] = push_file,
79
+ content_opted_in: bool = False,
78
80
  ) -> SyncOutcome:
79
81
  # Acquire is the one step before a lease exists: any failure here (network error,
80
82
  # unknown status, or an "acquired" response with no lease) must NOT attempt an
@@ -105,7 +107,11 @@ def run_portkey_sync(
105
107
  )
106
108
 
107
109
  lease = acquire.lease
108
- ctx = ImportContext(source=PORTKEY_SOURCE, source_scope=source_scope)
110
+ ctx = ImportContext(
111
+ source=PORTKEY_SOURCE,
112
+ source_scope=source_scope,
113
+ content_opted_in=content_opted_in,
114
+ )
109
115
  created_export_ids: list[str] = [] # every draft this run created, for best-effort cancel
110
116
 
111
117
  def renew() -> None:
@@ -138,6 +144,8 @@ def run_portkey_sync(
138
144
  _, sk = convert_portkey_export(
139
145
  raw, converted_path, import_context=ctx, on_progress=renewer.tick
140
146
  )
147
+ if not content_opted_in:
148
+ _make_content_blind(converted_path)
141
149
  _discard_file(raw) # raw fully consumed — free it before the upload
142
150
  skipped += sk
143
151
  renewer.force() # normalize done — force before the row-by-row upload
@@ -236,6 +244,20 @@ def _plan_exports(pk_client, lease, created_export_ids: list[str], renewer) -> l
236
244
  return export_ids
237
245
 
238
246
 
247
+ def _make_content_blind(path: str) -> None:
248
+ """Remove request and response content before a scheduled upload."""
249
+ temporary = f"{path}.content-blind"
250
+ with open(path, encoding="utf-8") as source, open(temporary, "w", encoding="utf-8") as target:
251
+ for line in source:
252
+ row = json.loads(line)
253
+ if isinstance(row, dict):
254
+ for field in ("request_json", "request_text", "response_text", "tool_calls"):
255
+ row.pop(field, None)
256
+ row["content_opted_in"] = False
257
+ target.write(json.dumps(row, separators=(",", ":")) + "\n")
258
+ os.replace(temporary, path)
259
+
260
+
239
261
  def _poll_all(export_ids, pk_client, renewer, sleep, poll_interval, max_poll_seconds, clock) -> None:
240
262
  """Poll every export to a terminal state, renewing the lease on a time cadence.
241
263
 
@@ -37,6 +37,7 @@ import os
37
37
  import sys
38
38
  import tempfile
39
39
  import time
40
+ import json
40
41
  from typing import Callable, Protocol
41
42
 
42
43
  from .metergraph_sync import LeaseLostError, MeterGraphSyncError
@@ -76,6 +77,7 @@ def run_pull_sync(
76
77
  ingest_base_url: str | None,
77
78
  provider_errors: tuple[type[Exception], ...] = (),
78
79
  allow_skipped: bool = False,
80
+ content_opted_in: bool = False,
79
81
  work_dir: str | None = None,
80
82
  clock: Callable[[], float] = time.monotonic,
81
83
  renew_interval_seconds: float = RENEW_INTERVAL_SECONDS,
@@ -111,7 +113,11 @@ def run_pull_sync(
111
113
  )
112
114
 
113
115
  lease = acquire.lease
114
- ctx = ImportContext(source=source, source_scope=source_scope)
116
+ ctx = ImportContext(
117
+ source=source,
118
+ source_scope=source_scope,
119
+ content_opted_in=content_opted_in,
120
+ )
115
121
 
116
122
  def renew() -> None:
117
123
  mg_client.renew(lease.lease_id)
@@ -130,6 +136,8 @@ def run_pull_sync(
130
136
  import_context=ctx,
131
137
  on_progress=renewer.tick,
132
138
  )
139
+ if not ctx.content_opted_in:
140
+ _make_content_blind(rows_path)
133
141
  renewer.force() # pull done — force before the row-by-row upload
134
142
  if skipped and not allow_skipped:
135
143
  # In sync mode there is no export file to recover a skipped row
@@ -204,6 +212,20 @@ def run_pull_sync(
204
212
  raise
205
213
 
206
214
 
215
+ def _make_content_blind(path: str) -> None:
216
+ """Remove provider content before a scheduled import can upload it."""
217
+ temporary = f"{path}.content-blind"
218
+ with open(path, encoding="utf-8") as source, open(temporary, "w", encoding="utf-8") as target:
219
+ for line in source:
220
+ row = json.loads(line)
221
+ if isinstance(row, dict):
222
+ for field in ("request_json", "request_text", "response_text", "tool_calls"):
223
+ row.pop(field, None)
224
+ row["content_opted_in"] = False
225
+ target.write(json.dumps(row, separators=(",", ":")) + "\n")
226
+ os.replace(temporary, path)
227
+
228
+
207
229
  def _already_sent(phase: str, pushed: int) -> str:
208
230
  """Say so when rows reached the server before the failure.
209
231
 
@@ -9,8 +9,10 @@ import urllib.request
9
9
  from typing import Any, Callable
10
10
 
11
11
  from .. import __version__
12
+ from ..http_limits import ResponseTooLarge, read_bounded
12
13
  from ..billing_evidence import reported_cost
13
14
  from ..capture_contract import capture_response_text, capture_row, capture_tool_calls
15
+ from ..finish_reason import extract_stop_reason
14
16
  from ..import_identity import ImportContext, canonical_import_event_id
15
17
  from ..window import normalize_utc_designator
16
18
 
@@ -20,6 +22,8 @@ from ..window import normalize_utc_designator
20
22
  DEFAULT_BRAINTRUST_URL = "https://api.braintrust.dev"
21
23
  BTQL_PATH = "/btql"
22
24
  PAGE_LIMIT = 1000
25
+ MAX_PAGES_PER_PULL = 10_000
26
+ MAX_ITEMS_PER_PULL = 100_000
23
27
  # span_attributes.type is Braintrust's own span classifier ("llm", "score",
24
28
  # "function", "eval", "task", "tool", "review"). Only "llm" spans are model
25
29
  # calls; every other type is application/eval structure and is never imported.
@@ -154,7 +158,7 @@ def fetch_spans_page(
154
158
  with urllib.request.urlopen(
155
159
  request, timeout=REQUEST_TIMEOUT_SECONDS
156
160
  ) as response:
157
- raw = response.read()
161
+ raw = read_bounded(response)
158
162
  cursor = _read_cursor(response.headers)
159
163
  except urllib.error.HTTPError as exc:
160
164
  raise BraintrustAPIError(
@@ -164,9 +168,11 @@ def fetch_spans_page(
164
168
  raise BraintrustAPIError(
165
169
  f"Braintrust API request failed: {exc.reason}"
166
170
  ) from exc
171
+ except ResponseTooLarge as exc:
172
+ raise BraintrustAPIError(str(exc)) from exc
167
173
  try:
168
174
  payload = json.loads(raw)
169
- except json.JSONDecodeError as exc:
175
+ except (json.JSONDecodeError, RecursionError) as exc:
170
176
  raise BraintrustAPIError(
171
177
  f"Braintrust API returned invalid JSON: {exc}"
172
178
  ) from exc
@@ -484,6 +490,7 @@ def normalize_span(
484
490
  usage = map_metrics(metrics)
485
491
  request_json, request_text = _map_content(span.get("input"))
486
492
  response_text, tool_calls = _extract_output(span.get("output"))
493
+ stop_reason = extract_stop_reason(span)
487
494
 
488
495
  span_parents = span.get("span_parents")
489
496
  parent_span_id = (
@@ -530,6 +537,8 @@ def normalize_span(
530
537
  "span_id": span.get("span_id") or span["id"],
531
538
  "parent_span_id": parent_span_id,
532
539
  }
540
+ if stop_reason is not None:
541
+ row["stop_reason"] = stop_reason
533
542
  if import_context is not None:
534
543
  # The row id is Braintrust's stable identity for this span, so an
535
544
  # overlap re-pull deduplicates on the server instead of double counting.
@@ -563,6 +572,8 @@ def pull_braintrust(
563
572
  skipped = 0
564
573
  cursor: str | None = None
565
574
  used_cursors: set[str] = set()
575
+ page_count = 0
576
+ item_count = 0
566
577
  # The cursor is bound to the query that produced it, so every page must ask
567
578
  # for the same LIMIT — only the OFFSET clause changes between pages. The cap
568
579
  # on `count` is enforced while writing rows instead.
@@ -575,6 +586,11 @@ def pull_braintrust(
575
586
  try:
576
587
  with os.fdopen(fd, "w") as f:
577
588
  while imported < count:
589
+ page_count += 1
590
+ if page_count > MAX_PAGES_PER_PULL:
591
+ raise BraintrustAPIError(
592
+ f"Braintrust pull exceeded the maximum of {MAX_PAGES_PER_PULL} pages"
593
+ )
578
594
  if cursor is not None:
579
595
  if cursor in used_cursors:
580
596
  raise BraintrustAPIError(
@@ -597,6 +613,11 @@ def pull_braintrust(
597
613
  on_progress() # a page fetch is progress too
598
614
  if not spans:
599
615
  break
616
+ item_count += len(spans)
617
+ if item_count > MAX_ITEMS_PER_PULL:
618
+ raise BraintrustAPIError(
619
+ f"Braintrust pull exceeded the maximum of {MAX_ITEMS_PER_PULL} items"
620
+ )
600
621
  for span in spans:
601
622
  if imported >= count:
602
623
  break