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