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.
- {metergraphrelay-0.7.2/src/metergraphrelay.egg-info → metergraphrelay-0.7.3}/PKG-INFO +2 -2
- {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/README.md +1 -1
- {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/pyproject.toml +2 -2
- metergraphrelay-0.7.3/src/metergraphrelay/__init__.py +1 -0
- {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/src/metergraphrelay/cli.py +19 -78
- metergraphrelay-0.7.3/src/metergraphrelay/finish_reason.py +107 -0
- metergraphrelay-0.7.3/src/metergraphrelay/http_limits.py +36 -0
- {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/src/metergraphrelay/import_identity.py +1 -0
- {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/src/metergraphrelay/portkey_sync.py +23 -1
- {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/src/metergraphrelay/provider_sync.py +23 -1
- {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/src/metergraphrelay/providers/braintrust.py +23 -2
- {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/src/metergraphrelay/providers/langfuse.py +23 -2
- {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/src/metergraphrelay/providers/langsmith.py +23 -2
- {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/src/metergraphrelay/providers/phoenix.py +23 -2
- {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/src/metergraphrelay/providers/portkey.py +5 -1
- {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/src/metergraphrelay/providers/portkey_export.py +30 -3
- {metergraphrelay-0.7.2 → metergraphrelay-0.7.3/src/metergraphrelay.egg-info}/PKG-INFO +2 -2
- {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/src/metergraphrelay.egg-info/SOURCES.txt +3 -0
- {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/tests/test_cli.py +21 -37
- metergraphrelay-0.7.3/tests/test_finish_reason.py +29 -0
- {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/tests/test_portkey_sync.py +2 -0
- metergraphrelay-0.7.2/src/metergraphrelay/__init__.py +0 -1
- {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/LICENSE +0 -0
- {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/setup.cfg +0 -0
- {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/src/metergraphrelay/billing_evidence.py +0 -0
- {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/src/metergraphrelay/capture_contract.py +0 -0
- {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/src/metergraphrelay/config.py +0 -0
- {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/src/metergraphrelay/demo.py +0 -0
- {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/src/metergraphrelay/metergraph_sync.py +0 -0
- {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/src/metergraphrelay/providers/__init__.py +0 -0
- {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/src/metergraphrelay/providers/openai.py +0 -0
- {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/src/metergraphrelay/push.py +0 -0
- {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/src/metergraphrelay/py.typed +0 -0
- {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/src/metergraphrelay/sync_core.py +0 -0
- {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/src/metergraphrelay/window.py +0 -0
- {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/src/metergraphrelay.egg-info/dependency_links.txt +0 -0
- {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/src/metergraphrelay.egg-info/entry_points.txt +0 -0
- {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/src/metergraphrelay.egg-info/requires.txt +0 -0
- {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/src/metergraphrelay.egg-info/top_level.txt +0 -0
- {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/tests/test_billing_evidence.py +0 -0
- {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/tests/test_capture_contract.py +0 -0
- {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/tests/test_config.py +0 -0
- {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/tests/test_demo.py +0 -0
- {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/tests/test_metergraph_sync.py +0 -0
- {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/tests/test_provider_sync.py +0 -0
- {metergraphrelay-0.7.2 → metergraphrelay-0.7.3}/tests/test_push.py +0 -0
- {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.
|
|
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` (
|
|
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` (
|
|
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
|
|
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.
|
|
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.
|
|
506
|
-
"
|
|
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 =
|
|
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 =
|
|
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
|
|
@@ -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(
|
|
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(
|
|
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
|
|
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
|