metergraphrelay 0.7.2__tar.gz → 0.7.5__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 (48) hide show
  1. {metergraphrelay-0.7.2/src/metergraphrelay.egg-info → metergraphrelay-0.7.5}/PKG-INFO +26 -2
  2. {metergraphrelay-0.7.2 → metergraphrelay-0.7.5}/README.md +25 -1
  3. {metergraphrelay-0.7.2 → metergraphrelay-0.7.5}/pyproject.toml +2 -2
  4. metergraphrelay-0.7.5/src/metergraphrelay/__init__.py +1 -0
  5. {metergraphrelay-0.7.2 → metergraphrelay-0.7.5}/src/metergraphrelay/cli.py +19 -78
  6. metergraphrelay-0.7.5/src/metergraphrelay/finish_reason.py +107 -0
  7. metergraphrelay-0.7.5/src/metergraphrelay/http_limits.py +36 -0
  8. {metergraphrelay-0.7.2 → metergraphrelay-0.7.5}/src/metergraphrelay/import_identity.py +1 -0
  9. {metergraphrelay-0.7.2 → metergraphrelay-0.7.5}/src/metergraphrelay/portkey_sync.py +23 -1
  10. {metergraphrelay-0.7.2 → metergraphrelay-0.7.5}/src/metergraphrelay/provider_sync.py +23 -1
  11. {metergraphrelay-0.7.2 → metergraphrelay-0.7.5}/src/metergraphrelay/providers/braintrust.py +23 -2
  12. metergraphrelay-0.7.5/src/metergraphrelay/providers/cache_shape.py +21 -0
  13. {metergraphrelay-0.7.2 → metergraphrelay-0.7.5}/src/metergraphrelay/providers/langfuse.py +25 -5
  14. {metergraphrelay-0.7.2 → metergraphrelay-0.7.5}/src/metergraphrelay/providers/langsmith.py +23 -2
  15. {metergraphrelay-0.7.2 → metergraphrelay-0.7.5}/src/metergraphrelay/providers/phoenix.py +23 -2
  16. {metergraphrelay-0.7.2 → metergraphrelay-0.7.5}/src/metergraphrelay/providers/portkey.py +39 -1
  17. {metergraphrelay-0.7.2 → metergraphrelay-0.7.5}/src/metergraphrelay/providers/portkey_export.py +30 -3
  18. {metergraphrelay-0.7.2 → metergraphrelay-0.7.5/src/metergraphrelay.egg-info}/PKG-INFO +26 -2
  19. {metergraphrelay-0.7.2 → metergraphrelay-0.7.5}/src/metergraphrelay.egg-info/SOURCES.txt +4 -0
  20. {metergraphrelay-0.7.2 → metergraphrelay-0.7.5}/tests/test_cli.py +21 -37
  21. metergraphrelay-0.7.5/tests/test_finish_reason.py +29 -0
  22. {metergraphrelay-0.7.2 → metergraphrelay-0.7.5}/tests/test_portkey_sync.py +2 -0
  23. metergraphrelay-0.7.2/src/metergraphrelay/__init__.py +0 -1
  24. {metergraphrelay-0.7.2 → metergraphrelay-0.7.5}/LICENSE +0 -0
  25. {metergraphrelay-0.7.2 → metergraphrelay-0.7.5}/setup.cfg +0 -0
  26. {metergraphrelay-0.7.2 → metergraphrelay-0.7.5}/src/metergraphrelay/billing_evidence.py +0 -0
  27. {metergraphrelay-0.7.2 → metergraphrelay-0.7.5}/src/metergraphrelay/capture_contract.py +0 -0
  28. {metergraphrelay-0.7.2 → metergraphrelay-0.7.5}/src/metergraphrelay/config.py +0 -0
  29. {metergraphrelay-0.7.2 → metergraphrelay-0.7.5}/src/metergraphrelay/demo.py +0 -0
  30. {metergraphrelay-0.7.2 → metergraphrelay-0.7.5}/src/metergraphrelay/metergraph_sync.py +0 -0
  31. {metergraphrelay-0.7.2 → metergraphrelay-0.7.5}/src/metergraphrelay/providers/__init__.py +0 -0
  32. {metergraphrelay-0.7.2 → metergraphrelay-0.7.5}/src/metergraphrelay/providers/openai.py +0 -0
  33. {metergraphrelay-0.7.2 → metergraphrelay-0.7.5}/src/metergraphrelay/push.py +0 -0
  34. {metergraphrelay-0.7.2 → metergraphrelay-0.7.5}/src/metergraphrelay/py.typed +0 -0
  35. {metergraphrelay-0.7.2 → metergraphrelay-0.7.5}/src/metergraphrelay/sync_core.py +0 -0
  36. {metergraphrelay-0.7.2 → metergraphrelay-0.7.5}/src/metergraphrelay/window.py +0 -0
  37. {metergraphrelay-0.7.2 → metergraphrelay-0.7.5}/src/metergraphrelay.egg-info/dependency_links.txt +0 -0
  38. {metergraphrelay-0.7.2 → metergraphrelay-0.7.5}/src/metergraphrelay.egg-info/entry_points.txt +0 -0
  39. {metergraphrelay-0.7.2 → metergraphrelay-0.7.5}/src/metergraphrelay.egg-info/requires.txt +0 -0
  40. {metergraphrelay-0.7.2 → metergraphrelay-0.7.5}/src/metergraphrelay.egg-info/top_level.txt +0 -0
  41. {metergraphrelay-0.7.2 → metergraphrelay-0.7.5}/tests/test_billing_evidence.py +0 -0
  42. {metergraphrelay-0.7.2 → metergraphrelay-0.7.5}/tests/test_capture_contract.py +0 -0
  43. {metergraphrelay-0.7.2 → metergraphrelay-0.7.5}/tests/test_config.py +0 -0
  44. {metergraphrelay-0.7.2 → metergraphrelay-0.7.5}/tests/test_demo.py +0 -0
  45. {metergraphrelay-0.7.2 → metergraphrelay-0.7.5}/tests/test_metergraph_sync.py +0 -0
  46. {metergraphrelay-0.7.2 → metergraphrelay-0.7.5}/tests/test_provider_sync.py +0 -0
  47. {metergraphrelay-0.7.2 → metergraphrelay-0.7.5}/tests/test_push.py +0 -0
  48. {metergraphrelay-0.7.2 → metergraphrelay-0.7.5}/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.5
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
@@ -151,6 +151,30 @@ so `status` is always `"success"`. `error`/`error_type` flag a *partial*
151
151
  record: `--include-content` was requested but the follow-up message fetch
152
152
  failed, so token counts are still real while the content is missing.
153
153
 
154
+ ### Token counts follow the publisher
155
+
156
+ A record carries token counts the way the model's publisher bills them, and
157
+ the metergraph catalog applies that publisher's arithmetic. Providers do not
158
+ agree on what `input_tokens` contains:
159
+
160
+ | Publisher | `input_tokens` contains cached tokens? | Cache buckets |
161
+ | --- | --- | --- |
162
+ | OpenAI, Google, DeepSeek, xAI | Yes, as a total | `cache_read_tokens` is a subset of input |
163
+ | Anthropic, Bedrock | No | `cache_read_tokens` and `cache_write_tokens` are separate buckets, added to input |
164
+
165
+ Gateways and tracing tools reshape these. Portkey's `req_units` is a total
166
+ for every provider; Vercel's gateway reports totals; Langfuse subtracts the
167
+ buckets for every provider. A relay adapter converts whatever shape it
168
+ receives back into the publisher's shape, using the provider's own usage
169
+ block where the source passes it through. Keeping a gateway's total for an
170
+ Anthropic call bills every cached token twice, once in input and once at the
171
+ cache rate; folding Langfuse's buckets into an OpenAI total does the same.
172
+ The gateway's own amount, where it reports one, is kept separately as
173
+ `reported_cost_usd`, so nothing the gateway said is lost.
174
+
175
+ `providers/cache_shape.py` names the publishers that count without the cache
176
+ buckets; add a provider there, not in an adapter.
177
+
154
178
  ## Pull from Langfuse
155
179
 
156
180
  Import Langfuse **GENERATION** observations (the LLM call records
@@ -309,7 +333,7 @@ but LLM spans is ever imported, but `--name` has no effect there.
309
333
 
310
334
  **Setup:** nothing, for a local Phoenix. The default base URL is
311
335
  `http://localhost:6006`. For a remote or authenticated Phoenix, set these
312
- in `.env` (or pass `--base-url` / `--phoenix-api-key` per-command):
336
+ in `.env` (and pass `--base-url` only when selecting a different endpoint):
313
337
 
314
338
  PHOENIX_BASE_URL=https://phoenix.example.com
315
339
  PHOENIX_API_KEY=...
@@ -128,6 +128,30 @@ so `status` is always `"success"`. `error`/`error_type` flag a *partial*
128
128
  record: `--include-content` was requested but the follow-up message fetch
129
129
  failed, so token counts are still real while the content is missing.
130
130
 
131
+ ### Token counts follow the publisher
132
+
133
+ A record carries token counts the way the model's publisher bills them, and
134
+ the metergraph catalog applies that publisher's arithmetic. Providers do not
135
+ agree on what `input_tokens` contains:
136
+
137
+ | Publisher | `input_tokens` contains cached tokens? | Cache buckets |
138
+ | --- | --- | --- |
139
+ | OpenAI, Google, DeepSeek, xAI | Yes, as a total | `cache_read_tokens` is a subset of input |
140
+ | Anthropic, Bedrock | No | `cache_read_tokens` and `cache_write_tokens` are separate buckets, added to input |
141
+
142
+ Gateways and tracing tools reshape these. Portkey's `req_units` is a total
143
+ for every provider; Vercel's gateway reports totals; Langfuse subtracts the
144
+ buckets for every provider. A relay adapter converts whatever shape it
145
+ receives back into the publisher's shape, using the provider's own usage
146
+ block where the source passes it through. Keeping a gateway's total for an
147
+ Anthropic call bills every cached token twice, once in input and once at the
148
+ cache rate; folding Langfuse's buckets into an OpenAI total does the same.
149
+ The gateway's own amount, where it reports one, is kept separately as
150
+ `reported_cost_usd`, so nothing the gateway said is lost.
151
+
152
+ `providers/cache_shape.py` names the publishers that count without the cache
153
+ buckets; add a provider there, not in an adapter.
154
+
131
155
  ## Pull from Langfuse
132
156
 
133
157
  Import Langfuse **GENERATION** observations (the LLM call records
@@ -286,7 +310,7 @@ but LLM spans is ever imported, but `--name` has no effect there.
286
310
 
287
311
  **Setup:** nothing, for a local Phoenix. The default base URL is
288
312
  `http://localhost:6006`. For a remote or authenticated Phoenix, set these
289
- in `.env` (or pass `--base-url` / `--phoenix-api-key` per-command):
313
+ in `.env` (and pass `--base-url` only when selecting a different endpoint):
290
314
 
291
315
  PHOENIX_BASE_URL=https://phoenix.example.com
292
316
  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.5"
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.5"
@@ -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