metergraphrelay 0.7.0__tar.gz → 0.7.2__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.0/src/metergraphrelay.egg-info → metergraphrelay-0.7.2}/PKG-INFO +4 -1
- {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/README.md +3 -0
- {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/pyproject.toml +1 -1
- metergraphrelay-0.7.2/src/metergraphrelay/__init__.py +1 -0
- metergraphrelay-0.7.2/src/metergraphrelay/billing_evidence.py +43 -0
- {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/src/metergraphrelay/capture_contract.py +50 -4
- {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/src/metergraphrelay/portkey_sync.py +14 -5
- {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/src/metergraphrelay/providers/braintrust.py +7 -7
- {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/src/metergraphrelay/providers/langfuse.py +51 -10
- {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/src/metergraphrelay/providers/langsmith.py +5 -2
- {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/src/metergraphrelay/providers/openai.py +65 -4
- {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/src/metergraphrelay/providers/phoenix.py +3 -2
- {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/src/metergraphrelay/providers/portkey.py +207 -5
- {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/src/metergraphrelay/providers/portkey_export.py +1 -1
- {metergraphrelay-0.7.0 → metergraphrelay-0.7.2/src/metergraphrelay.egg-info}/PKG-INFO +4 -1
- {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/src/metergraphrelay.egg-info/SOURCES.txt +2 -0
- metergraphrelay-0.7.2/tests/test_billing_evidence.py +39 -0
- {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/tests/test_capture_contract.py +31 -0
- {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/tests/test_portkey_sync.py +59 -0
- metergraphrelay-0.7.0/src/metergraphrelay/__init__.py +0 -1
- {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/LICENSE +0 -0
- {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/setup.cfg +0 -0
- {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/src/metergraphrelay/cli.py +0 -0
- {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/src/metergraphrelay/config.py +0 -0
- {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/src/metergraphrelay/demo.py +0 -0
- {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/src/metergraphrelay/import_identity.py +0 -0
- {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/src/metergraphrelay/metergraph_sync.py +0 -0
- {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/src/metergraphrelay/provider_sync.py +0 -0
- {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/src/metergraphrelay/providers/__init__.py +0 -0
- {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/src/metergraphrelay/push.py +0 -0
- {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/src/metergraphrelay/py.typed +0 -0
- {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/src/metergraphrelay/sync_core.py +0 -0
- {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/src/metergraphrelay/window.py +0 -0
- {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/src/metergraphrelay.egg-info/dependency_links.txt +0 -0
- {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/src/metergraphrelay.egg-info/entry_points.txt +0 -0
- {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/src/metergraphrelay.egg-info/requires.txt +0 -0
- {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/src/metergraphrelay.egg-info/top_level.txt +0 -0
- {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/tests/test_cli.py +0 -0
- {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/tests/test_config.py +0 -0
- {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/tests/test_demo.py +0 -0
- {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/tests/test_metergraph_sync.py +0 -0
- {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/tests/test_provider_sync.py +0 -0
- {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/tests/test_push.py +0 -0
- {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/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.2
|
|
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
|
|
@@ -551,6 +551,9 @@ records**, the run splits it **once** into **10 sub-windows with
|
|
|
551
551
|
absorbs the boundary overlaps). This split is one-shot, never recursive:
|
|
552
552
|
if any single sub-window *still* exceeds 50,000 records, the run fails
|
|
553
553
|
with a clear error rather than splitting further.
|
|
554
|
+
Before splitting, the run cancels the never-started hourly draft on a best effort
|
|
555
|
+
basis. If Portkey refuses the cancel, the run warns and continues because an
|
|
556
|
+
unstarted draft only counts rows and exports nothing.
|
|
554
557
|
|
|
555
558
|
**Before running this against your own data:** as in manual mode,
|
|
556
559
|
request and response content is uploaded to metergraph, with no opt-out.
|
|
@@ -528,6 +528,9 @@ records**, the run splits it **once** into **10 sub-windows with
|
|
|
528
528
|
absorbs the boundary overlaps). This split is one-shot, never recursive:
|
|
529
529
|
if any single sub-window *still* exceeds 50,000 records, the run fails
|
|
530
530
|
with a clear error rather than splitting further.
|
|
531
|
+
Before splitting, the run cancels the never-started hourly draft on a best effort
|
|
532
|
+
basis. If Portkey refuses the cancel, the run warns and continues because an
|
|
533
|
+
unstarted draft only counts rows and exports nothing.
|
|
531
534
|
|
|
532
535
|
**Before running this against your own data:** as in manual mode,
|
|
533
536
|
request and response content is uploaded to metergraph, with no opt-out.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "metergraphrelay"
|
|
7
|
-
version = "0.7.
|
|
7
|
+
version = "0.7.2"
|
|
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.2"
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""How an importer states where a cost figure came from.
|
|
2
|
+
|
|
3
|
+
metergraph selects an effective cost from its own catalog and from amounts a
|
|
4
|
+
source reported. It can only weigh a reported amount if it knows what produced
|
|
5
|
+
it, because the field carrying one is open: a figure a customer's own client
|
|
6
|
+
wrote and a figure a gateway billed arrive in the same place and look alike.
|
|
7
|
+
|
|
8
|
+
Two kinds of source report a cost, and they are not equivalent:
|
|
9
|
+
|
|
10
|
+
* A **gateway** sits in the request path and states what the call cost. Naming
|
|
11
|
+
it lets that amount be treated as billing evidence.
|
|
12
|
+
* An **observability platform** reports its own estimate, computed from the
|
|
13
|
+
tokens it observed against a price table it maintains. Braintrust calls the
|
|
14
|
+
field ``estimated_cost``, which is the honest description of all of them.
|
|
15
|
+
|
|
16
|
+
So an observability platform names its source and no gateway, while a gateway
|
|
17
|
+
states itself on the row. The amount is still recorded either way, and an audit
|
|
18
|
+
can compare it against a computed cost, but nothing can mistake an estimate for
|
|
19
|
+
something the provider charged.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from decimal import Decimal, InvalidOperation
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def reported_cost(amount: Any, *, source: str) -> dict:
|
|
29
|
+
"""The fields naming a reported cost, or nothing when there is no amount.
|
|
30
|
+
|
|
31
|
+
The amount is carried as a decimal string. A cost lands in a numeric column,
|
|
32
|
+
and a value divided or parsed as a binary float arrives carrying rounding
|
|
33
|
+
that no later step can remove.
|
|
34
|
+
"""
|
|
35
|
+
if amount is None or isinstance(amount, bool):
|
|
36
|
+
return {}
|
|
37
|
+
try:
|
|
38
|
+
parsed = Decimal(str(amount))
|
|
39
|
+
except (InvalidOperation, TypeError, ValueError):
|
|
40
|
+
return {}
|
|
41
|
+
if not parsed.is_finite() or parsed < 0:
|
|
42
|
+
return {}
|
|
43
|
+
return {"reported_cost_usd": str(parsed), "reported_cost_source": source}
|
|
@@ -14,6 +14,9 @@ Two rules bind an importer:
|
|
|
14
14
|
which the pipeline reads as native-search audit and passes over.
|
|
15
15
|
* ``response_text`` is a string. A response carrying no text is ``""``; ``None``
|
|
16
16
|
means the result itself is malformed.
|
|
17
|
+
* A numeric the source never recorded is absent, not ``None``. The pipeline
|
|
18
|
+
validates ``latency_ms``, ``input_tokens`` and ``output_tokens`` whenever the
|
|
19
|
+
key is present, so an explicit ``None`` reads as a malformed number.
|
|
17
20
|
|
|
18
21
|
Provider wire items that are neither a tool call nor search audit -- OpenAI
|
|
19
22
|
``reasoning`` items are the case seen in production -- have no representation in
|
|
@@ -30,6 +33,11 @@ from typing import Any
|
|
|
30
33
|
# exempt from the tool-call shape. Passed through with their fields intact.
|
|
31
34
|
NATIVE_SEARCH_AUDIT_TYPES = frozenset({"web_search_call", "web_fetch_call"})
|
|
32
35
|
|
|
36
|
+
# Validated by the pipeline on presence, not on value: carrying one of these as
|
|
37
|
+
# an explicit None says "this is the number" and fails, where leaving it out
|
|
38
|
+
# says "the source did not record one" and is accepted.
|
|
39
|
+
PRESENCE_CHECKED_NUMERIC_FIELDS = ("latency_ms", "input_tokens", "output_tokens")
|
|
40
|
+
|
|
33
41
|
|
|
34
42
|
def capture_text(parts: list[str]) -> str:
|
|
35
43
|
"""Join a response's text parts into the contract's ``response_text``.
|
|
@@ -40,6 +48,21 @@ def capture_text(parts: list[str]) -> str:
|
|
|
40
48
|
return "\n".join(parts)
|
|
41
49
|
|
|
42
50
|
|
|
51
|
+
def capture_response_text(value: Any, *, content_opted_in: bool = True) -> str | None:
|
|
52
|
+
"""A row's ``response_text``, as the contract requires it.
|
|
53
|
+
|
|
54
|
+
``None`` is reserved for a row carrying no content at all, which the
|
|
55
|
+
pipeline recognises as the opt-out. On a row whose content *was* captured it
|
|
56
|
+
means something else entirely -- a malformed result -- and takes the row out
|
|
57
|
+
of every analysis. A reply with nothing to say is ``""``.
|
|
58
|
+
"""
|
|
59
|
+
if not content_opted_in:
|
|
60
|
+
return None
|
|
61
|
+
if value is None:
|
|
62
|
+
return ""
|
|
63
|
+
return value if isinstance(value, str) else json.dumps(value)
|
|
64
|
+
|
|
65
|
+
|
|
43
66
|
def _arguments(value: Any) -> str:
|
|
44
67
|
"""Render a tool call's arguments as the contract's JSON string."""
|
|
45
68
|
if isinstance(value, str):
|
|
@@ -49,11 +72,18 @@ def _arguments(value: Any) -> str:
|
|
|
49
72
|
return json.dumps(value)
|
|
50
73
|
|
|
51
74
|
|
|
52
|
-
def capture_tool_call(item: Any) -> dict | None:
|
|
75
|
+
def capture_tool_call(item: Any, *, position: int | None = None) -> dict | None:
|
|
53
76
|
"""Convert one provider tool item to the contract shape.
|
|
54
77
|
|
|
55
78
|
Returns the item unchanged when it is native-search audit, and ``None`` for
|
|
56
79
|
anything with no contract representation, which the caller drops.
|
|
80
|
+
|
|
81
|
+
``position`` supplies a fallback identity for a recognised tool call whose
|
|
82
|
+
source recorded no id. Providers that log whatever an integration handed
|
|
83
|
+
them do occasionally omit it, and dropping the call would describe a tool
|
|
84
|
+
turn as though the model had replied with text alone. The contract needs the
|
|
85
|
+
id only to tell one call from another within a response, which the position
|
|
86
|
+
does. A call with no name is still dropped: there is nothing to identify.
|
|
57
87
|
"""
|
|
58
88
|
if not isinstance(item, dict):
|
|
59
89
|
return None
|
|
@@ -82,10 +112,12 @@ def capture_tool_call(item: Any) -> dict | None:
|
|
|
82
112
|
else:
|
|
83
113
|
return None
|
|
84
114
|
|
|
85
|
-
if not isinstance(call_id, str) or not call_id.strip():
|
|
86
|
-
return None
|
|
87
115
|
if not isinstance(name, str) or not name.strip():
|
|
88
116
|
return None
|
|
117
|
+
if not isinstance(call_id, str) or not call_id.strip():
|
|
118
|
+
if position is None:
|
|
119
|
+
return None
|
|
120
|
+
call_id = f"tool-{position}"
|
|
89
121
|
return {"call_id": call_id, "name": name, "arguments": _arguments(arguments)}
|
|
90
122
|
|
|
91
123
|
|
|
@@ -94,6 +126,20 @@ def capture_tool_calls(items: Any) -> list | None:
|
|
|
94
126
|
if not isinstance(items, (list, tuple)):
|
|
95
127
|
return None
|
|
96
128
|
converted = [
|
|
97
|
-
call
|
|
129
|
+
call
|
|
130
|
+
for position, item in enumerate(items)
|
|
131
|
+
if (call := capture_tool_call(item, position=position)) is not None
|
|
98
132
|
]
|
|
99
133
|
return converted or None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def capture_row(row: dict) -> dict:
|
|
137
|
+
"""Drop the numerics a source never recorded, so the row stays readable.
|
|
138
|
+
|
|
139
|
+
Every other field is passed through untouched.
|
|
140
|
+
"""
|
|
141
|
+
return {
|
|
142
|
+
key: value
|
|
143
|
+
for key, value in row.items()
|
|
144
|
+
if value is not None or key not in PRESENCE_CHECKED_NUMERIC_FIELDS
|
|
145
|
+
}
|
|
@@ -26,6 +26,7 @@ boundaries force an unconditional renewal.
|
|
|
26
26
|
from __future__ import annotations
|
|
27
27
|
|
|
28
28
|
import os
|
|
29
|
+
import sys
|
|
29
30
|
import tempfile
|
|
30
31
|
import time
|
|
31
32
|
from typing import Callable
|
|
@@ -182,9 +183,9 @@ def run_portkey_sync(
|
|
|
182
183
|
def _plan_exports(pk_client, lease, created_export_ids: list[str], renewer) -> list[str]:
|
|
183
184
|
"""Create the hourly draft; its ``total`` decides whether to split. No start/poll here.
|
|
184
185
|
|
|
185
|
-
``total > threshold`` cancels the still-unstarted hourly draft and
|
|
186
|
-
exactly 10 overlapping sub-window drafts. If any sub-window draft is
|
|
187
|
-
still oversized, reject with a clear error — the MVP never splits recursively.
|
|
186
|
+
``total > threshold`` best-effort cancels the still-unstarted hourly draft and
|
|
187
|
+
creates exactly 10 overlapping sub-window drafts. If any sub-window draft is
|
|
188
|
+
itself still oversized, reject with a clear error — the MVP never splits recursively.
|
|
188
189
|
Each create/cancel can be slow, so the lease is renewed on a time cadence
|
|
189
190
|
between them.
|
|
190
191
|
"""
|
|
@@ -196,8 +197,16 @@ def _plan_exports(pk_client, lease, created_export_ids: list[str], renewer) -> l
|
|
|
196
197
|
if hourly.total is None or hourly.total <= VOLUME_SPLIT_THRESHOLD:
|
|
197
198
|
return [hourly.export_id]
|
|
198
199
|
|
|
199
|
-
# Oversized: cancel the still-unstarted hourly draft, then split into
|
|
200
|
-
|
|
200
|
+
# Oversized: best-effort cancel the still-unstarted hourly draft, then split into
|
|
201
|
+
# exactly 10. An unstarted draft costs nothing, so a cancel refusal is non-fatal.
|
|
202
|
+
try:
|
|
203
|
+
pk_client.cancel_export(hourly.export_id)
|
|
204
|
+
except (PortkeyExportError, OSError) as exc:
|
|
205
|
+
print(
|
|
206
|
+
f"Warning: could not cancel export {hourly.export_id}: {exc}; the draft "
|
|
207
|
+
"was never started so it is left in place.",
|
|
208
|
+
file=sys.stderr,
|
|
209
|
+
)
|
|
201
210
|
created_export_ids.remove(hourly.export_id)
|
|
202
211
|
renewer.tick()
|
|
203
212
|
# split_window validates the server-provided bounds (aware, end-after-start). A
|
|
@@ -9,6 +9,8 @@ import urllib.request
|
|
|
9
9
|
from typing import Any, Callable
|
|
10
10
|
|
|
11
11
|
from .. import __version__
|
|
12
|
+
from ..billing_evidence import reported_cost
|
|
13
|
+
from ..capture_contract import capture_response_text, capture_row, capture_tool_calls
|
|
12
14
|
from ..import_identity import ImportContext, canonical_import_event_id
|
|
13
15
|
from ..window import normalize_utc_designator
|
|
14
16
|
|
|
@@ -279,14 +281,11 @@ def _content_blocks(blocks: list) -> tuple[str | None, list | None]:
|
|
|
279
281
|
text_parts.append(block["text"])
|
|
280
282
|
elif block.get("type") == "tool_use":
|
|
281
283
|
tool_calls.append(block)
|
|
282
|
-
return ("\n".join(text_parts) or None), (tool_calls
|
|
284
|
+
return ("\n".join(text_parts) or None), capture_tool_calls(tool_calls)
|
|
283
285
|
|
|
284
286
|
|
|
285
287
|
def _message_output(message: dict[str, Any]) -> tuple[str | None, list | None]:
|
|
286
|
-
|
|
287
|
-
tool_calls = (
|
|
288
|
-
raw_tool_calls if isinstance(raw_tool_calls, list) and raw_tool_calls else None
|
|
289
|
-
)
|
|
288
|
+
tool_calls = capture_tool_calls(message.get("tool_calls"))
|
|
290
289
|
content = message.get("content")
|
|
291
290
|
if isinstance(content, str):
|
|
292
291
|
return (content or None), tool_calls
|
|
@@ -515,6 +514,7 @@ def normalize_span(
|
|
|
515
514
|
"reasoning_tokens": usage["reasoning_tokens"],
|
|
516
515
|
"latency_ms": usage["latency_ms"],
|
|
517
516
|
"cost_usd": _cost_usd(span, metrics),
|
|
517
|
+
**reported_cost(_cost_usd(span, metrics), source="braintrust.estimated_cost"),
|
|
518
518
|
"error": error,
|
|
519
519
|
"error_type": _error_type(raw_error) if error else None,
|
|
520
520
|
"request_id": span["id"],
|
|
@@ -523,7 +523,7 @@ def normalize_span(
|
|
|
523
523
|
"content_opted_in": True,
|
|
524
524
|
"request_json": request_json,
|
|
525
525
|
"request_text": request_text,
|
|
526
|
-
"response_text": response_text,
|
|
526
|
+
"response_text": capture_response_text(response_text),
|
|
527
527
|
"tool_calls": tool_calls,
|
|
528
528
|
"tool_names": _tool_names(tool_calls),
|
|
529
529
|
"trace_id": span.get("root_span_id"),
|
|
@@ -536,7 +536,7 @@ def normalize_span(
|
|
|
536
536
|
row["import_source"] = import_context.source
|
|
537
537
|
row["import_source_scope"] = import_context.source_scope
|
|
538
538
|
row["import_event_id"] = canonical_import_event_id(span.get("id"))
|
|
539
|
-
return row
|
|
539
|
+
return capture_row(row)
|
|
540
540
|
|
|
541
541
|
|
|
542
542
|
def _cleanup_temp_file(tmp_path: str) -> None:
|
|
@@ -11,6 +11,8 @@ import urllib.request
|
|
|
11
11
|
from typing import Any, Callable
|
|
12
12
|
|
|
13
13
|
from .. import __version__
|
|
14
|
+
from ..billing_evidence import reported_cost
|
|
15
|
+
from ..capture_contract import capture_response_text, capture_row
|
|
14
16
|
from ..import_identity import ImportContext, canonical_import_event_id
|
|
15
17
|
|
|
16
18
|
DEFAULT_LANGFUSE_HOST = "https://cloud.langfuse.com"
|
|
@@ -296,6 +298,19 @@ _REASONING_KEYS = (
|
|
|
296
298
|
)
|
|
297
299
|
_NON_BUCKET_MARKERS = ("priority",)
|
|
298
300
|
|
|
301
|
+
# Providers whose own usage reports input *excluding* the cached tokens, which
|
|
302
|
+
# it lists separately. metergraph prices cache handling per publisher, so a row
|
|
303
|
+
# has to arrive in the shape that publisher's API uses: for one of these,
|
|
304
|
+
# folding the cache buckets into input makes those tokens part of the input
|
|
305
|
+
# total as well as a bucket of their own, and they are billed twice -- once at
|
|
306
|
+
# the input rate and once at the cache rate, an order of magnitude apart.
|
|
307
|
+
#
|
|
308
|
+
# For OpenAI and Google the opposite is true: their own totals include the
|
|
309
|
+
# cached tokens, so the buckets Langfuse subtracted do have to be added back.
|
|
310
|
+
_CACHE_EXCLUSIVE_INPUT_PROVIDERS = frozenset(
|
|
311
|
+
{"anthropic", "bedrock", "aws-bedrock", "aws", "amazon-bedrock"}
|
|
312
|
+
)
|
|
313
|
+
|
|
299
314
|
|
|
300
315
|
def _is_int(value: Any) -> bool:
|
|
301
316
|
return isinstance(value, int) and not isinstance(value, bool)
|
|
@@ -314,17 +329,26 @@ def _first_int(source: dict[str, Any], keys: tuple[str, ...]) -> int | None:
|
|
|
314
329
|
|
|
315
330
|
|
|
316
331
|
def _total_with_buckets(
|
|
317
|
-
usage_details: dict[str, Any],
|
|
332
|
+
usage_details: dict[str, Any],
|
|
333
|
+
total_keys: tuple[str, ...],
|
|
334
|
+
marker: str,
|
|
335
|
+
*,
|
|
336
|
+
skip_keys: tuple[str, ...] = (),
|
|
318
337
|
) -> int | None:
|
|
319
|
-
"""The named total plus every other integer bucket carrying ``marker``.
|
|
338
|
+
"""The named total plus every other integer bucket carrying ``marker``.
|
|
339
|
+
|
|
340
|
+
``skip_keys`` names buckets the total already excludes by the provider's own
|
|
341
|
+
convention, so adding them would count those tokens twice.
|
|
342
|
+
"""
|
|
320
343
|
total_key, total = _first_int_key(usage_details, total_keys)
|
|
321
344
|
if total_key is None:
|
|
322
345
|
return None
|
|
346
|
+
skipped = {key.lower() for key in skip_keys}
|
|
323
347
|
for key, value in usage_details.items():
|
|
324
348
|
if key == total_key or not _is_int(value):
|
|
325
349
|
continue
|
|
326
350
|
lowered = key.lower()
|
|
327
|
-
if marker not in lowered:
|
|
351
|
+
if marker not in lowered or lowered in skipped:
|
|
328
352
|
continue
|
|
329
353
|
if any(skip in lowered for skip in _NON_BUCKET_MARKERS):
|
|
330
354
|
continue
|
|
@@ -332,8 +356,15 @@ def _total_with_buckets(
|
|
|
332
356
|
return total
|
|
333
357
|
|
|
334
358
|
|
|
335
|
-
def map_usage_details(
|
|
336
|
-
|
|
359
|
+
def map_usage_details(
|
|
360
|
+
usage_details: Any, *, provider: str | None = None
|
|
361
|
+
) -> dict[str, int | None]:
|
|
362
|
+
"""Map one observation's usageDetails onto metergraph token fields.
|
|
363
|
+
|
|
364
|
+
``provider`` decides whether the cache buckets belong inside the input
|
|
365
|
+
total, because that is a property of the provider's own usage shape rather
|
|
366
|
+
than of Langfuse's.
|
|
367
|
+
"""
|
|
337
368
|
if not isinstance(usage_details, dict):
|
|
338
369
|
return {
|
|
339
370
|
"input_tokens": None,
|
|
@@ -342,8 +373,16 @@ def map_usage_details(usage_details: Any) -> dict[str, int | None]:
|
|
|
342
373
|
"cache_write_tokens": None,
|
|
343
374
|
"reasoning_tokens": None,
|
|
344
375
|
}
|
|
376
|
+
cache_exclusive = (
|
|
377
|
+
str(provider or "").strip().lower() in _CACHE_EXCLUSIVE_INPUT_PROVIDERS
|
|
378
|
+
)
|
|
345
379
|
return {
|
|
346
|
-
"input_tokens": _total_with_buckets(
|
|
380
|
+
"input_tokens": _total_with_buckets(
|
|
381
|
+
usage_details,
|
|
382
|
+
_INPUT_TOTAL_KEYS,
|
|
383
|
+
"input",
|
|
384
|
+
skip_keys=(_CACHE_READ_KEYS + _CACHE_WRITE_KEYS) if cache_exclusive else (),
|
|
385
|
+
),
|
|
347
386
|
"output_tokens": _total_with_buckets(usage_details, _OUTPUT_TOTAL_KEYS, "output"),
|
|
348
387
|
"cache_read_tokens": _first_int(usage_details, _CACHE_READ_KEYS),
|
|
349
388
|
"cache_write_tokens": _first_int(usage_details, _CACHE_WRITE_KEYS),
|
|
@@ -381,7 +420,8 @@ def normalize_observation(
|
|
|
381
420
|
raw_status_message if error and isinstance(raw_status_message, str) else None
|
|
382
421
|
)
|
|
383
422
|
|
|
384
|
-
|
|
423
|
+
provider = infer_provider(observation)
|
|
424
|
+
usage = map_usage_details(observation.get("usageDetails"), provider=provider)
|
|
385
425
|
request_json, request_text = _map_content(observation.get("input"))
|
|
386
426
|
response_text = _response_text(observation.get("output"))
|
|
387
427
|
|
|
@@ -392,7 +432,7 @@ def normalize_observation(
|
|
|
392
432
|
"source": "langfuse",
|
|
393
433
|
"sdk": "metergraphrelay",
|
|
394
434
|
"sdk_version": __version__,
|
|
395
|
-
"provider":
|
|
435
|
+
"provider": provider,
|
|
396
436
|
"model": model,
|
|
397
437
|
"status": "error" if error else "success",
|
|
398
438
|
"input_tokens": usage["input_tokens"],
|
|
@@ -401,6 +441,7 @@ def normalize_observation(
|
|
|
401
441
|
"cache_write_tokens": usage["cache_write_tokens"],
|
|
402
442
|
"reasoning_tokens": usage["reasoning_tokens"],
|
|
403
443
|
"cost_usd": observation.get("totalCost"),
|
|
444
|
+
**reported_cost(observation.get("totalCost"), source="langfuse.observation.totalCost"),
|
|
404
445
|
"error": error,
|
|
405
446
|
"error_type": error_type,
|
|
406
447
|
"request_id": observation["id"],
|
|
@@ -409,7 +450,7 @@ def normalize_observation(
|
|
|
409
450
|
"content_opted_in": True,
|
|
410
451
|
"request_json": request_json,
|
|
411
452
|
"request_text": request_text,
|
|
412
|
-
"response_text": response_text,
|
|
453
|
+
"response_text": capture_response_text(response_text),
|
|
413
454
|
"trace_id": observation["traceId"],
|
|
414
455
|
"span_id": observation["id"],
|
|
415
456
|
"parent_span_id": observation.get("parentObservationId"),
|
|
@@ -423,7 +464,7 @@ def normalize_observation(
|
|
|
423
464
|
row["import_source"] = import_context.source
|
|
424
465
|
row["import_source_scope"] = import_context.source_scope
|
|
425
466
|
row["import_event_id"] = canonical_import_event_id(observation.get("id"))
|
|
426
|
-
return row
|
|
467
|
+
return capture_row(row)
|
|
427
468
|
|
|
428
469
|
|
|
429
470
|
def _cleanup_temp_file(tmp_path: str) -> None:
|
|
@@ -39,6 +39,8 @@ from datetime import datetime
|
|
|
39
39
|
from typing import Any, Callable
|
|
40
40
|
|
|
41
41
|
from .. import __version__
|
|
42
|
+
from ..billing_evidence import reported_cost
|
|
43
|
+
from ..capture_contract import capture_response_text, capture_row
|
|
42
44
|
from ..import_identity import ImportContext, canonical_import_event_id
|
|
43
45
|
from ..window import normalize_utc_designator
|
|
44
46
|
|
|
@@ -472,6 +474,7 @@ def normalize_run(
|
|
|
472
474
|
"reasoning_tokens": usage["reasoning_tokens"],
|
|
473
475
|
"latency_ms": _latency_ms(run.get("start_time"), run.get("end_time")),
|
|
474
476
|
"cost_usd": _cost_usd(run),
|
|
477
|
+
**reported_cost(_cost_usd(run), source="langsmith.total_cost"),
|
|
475
478
|
"error": error,
|
|
476
479
|
"error_type": error_type,
|
|
477
480
|
"request_id": run_id,
|
|
@@ -480,7 +483,7 @@ def normalize_run(
|
|
|
480
483
|
"content_opted_in": True,
|
|
481
484
|
"request_json": request_json,
|
|
482
485
|
"request_text": request_text,
|
|
483
|
-
"response_text": _response_text(run.get("outputs")),
|
|
486
|
+
"response_text": capture_response_text(_response_text(run.get("outputs"))),
|
|
484
487
|
"trace_id": run.get("trace_id"),
|
|
485
488
|
"span_id": run_id,
|
|
486
489
|
"parent_span_id": run.get("parent_run_id"),
|
|
@@ -491,7 +494,7 @@ def normalize_run(
|
|
|
491
494
|
row["import_source"] = import_context.source
|
|
492
495
|
row["import_source_scope"] = import_context.source_scope
|
|
493
496
|
row["import_event_id"] = canonical_import_event_id(run.get("id"))
|
|
494
|
-
return row
|
|
497
|
+
return capture_row(row)
|
|
495
498
|
|
|
496
499
|
|
|
497
500
|
def _cleanup_temp_file(tmp_path: str) -> None:
|
|
@@ -7,6 +7,55 @@ from itertools import islice
|
|
|
7
7
|
from typing import Any, Iterable
|
|
8
8
|
|
|
9
9
|
from .. import __version__
|
|
10
|
+
from ..capture_contract import capture_response_text, capture_row, capture_tool_calls
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _as_mapping(call: Any) -> Any:
|
|
14
|
+
"""An SDK tool-call object as a plain mapping, however the client models it."""
|
|
15
|
+
if isinstance(call, dict):
|
|
16
|
+
return call
|
|
17
|
+
for attribute in ("model_dump", "to_dict", "dict"):
|
|
18
|
+
dump = getattr(call, attribute, None)
|
|
19
|
+
if callable(dump):
|
|
20
|
+
try:
|
|
21
|
+
return dump()
|
|
22
|
+
except TypeError:
|
|
23
|
+
continue
|
|
24
|
+
return getattr(call, "__dict__", {})
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _usage_int(usage: Any, group: str, field: str) -> int | None:
|
|
28
|
+
"""One nested usage count, when the provider recorded it.
|
|
29
|
+
|
|
30
|
+
Absent and zero are different facts: a provider reporting 0 says the feature
|
|
31
|
+
was off for that call, where a missing count says nothing at all and must
|
|
32
|
+
not be read as zero.
|
|
33
|
+
"""
|
|
34
|
+
detail = getattr(usage, group, None)
|
|
35
|
+
value = getattr(detail, field, None)
|
|
36
|
+
if value is None and isinstance(detail, dict):
|
|
37
|
+
value = detail.get(field)
|
|
38
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
39
|
+
return None
|
|
40
|
+
return value
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _usage_detail(usage: Any) -> dict[str, int]:
|
|
44
|
+
"""Counts that sit beside the totals and are billed at their own rates.
|
|
45
|
+
|
|
46
|
+
`prompt_tokens` already includes the cached tokens, so a row without the
|
|
47
|
+
cache count bills them at the full input rate rather than the far cheaper
|
|
48
|
+
cache rate. Reasoning tokens are already inside `completion_tokens` and are
|
|
49
|
+
carried as detail only, never added to the total.
|
|
50
|
+
"""
|
|
51
|
+
detail = {}
|
|
52
|
+
cached = _usage_int(usage, "prompt_tokens_details", "cached_tokens")
|
|
53
|
+
if cached is not None:
|
|
54
|
+
detail["cache_read_tokens"] = cached
|
|
55
|
+
reasoning = _usage_int(usage, "completion_tokens_details", "reasoning_tokens")
|
|
56
|
+
if reasoning is not None:
|
|
57
|
+
detail["reasoning_tokens"] = reasoning
|
|
58
|
+
return detail
|
|
10
59
|
|
|
11
60
|
|
|
12
61
|
def normalize_completion(
|
|
@@ -25,6 +74,7 @@ def normalize_completion(
|
|
|
25
74
|
content_opted_in = include_content and content_fetch_error is None
|
|
26
75
|
request_json: str | None = None
|
|
27
76
|
response_text: str | None = None
|
|
77
|
+
tool_calls: list | None = None
|
|
28
78
|
if content_opted_in:
|
|
29
79
|
request_json = json.dumps(
|
|
30
80
|
[{"role": m.role, "content": m.content} for m in message_list]
|
|
@@ -33,9 +83,16 @@ def normalize_completion(
|
|
|
33
83
|
# the model's own reply — that lives on the completion object itself.
|
|
34
84
|
choices = getattr(completion, "choices", None) or []
|
|
35
85
|
if choices:
|
|
36
|
-
|
|
86
|
+
message = choices[0].message
|
|
87
|
+
response_text = getattr(message, "content", None)
|
|
88
|
+
# A tool-only reply carries its answer in the tool call and no text.
|
|
89
|
+
# Recording the empty text without the call would describe the turn
|
|
90
|
+
# as an empty response rather than the tool use it was.
|
|
91
|
+
tool_calls = capture_tool_calls(
|
|
92
|
+
[_as_mapping(call) for call in getattr(message, "tool_calls", None) or []]
|
|
93
|
+
)
|
|
37
94
|
|
|
38
|
-
return {
|
|
95
|
+
return capture_row({
|
|
39
96
|
"ts": ts,
|
|
40
97
|
"provider": "openai",
|
|
41
98
|
"model": completion.model,
|
|
@@ -43,6 +100,7 @@ def normalize_completion(
|
|
|
43
100
|
"endpoint": "chat.completions",
|
|
44
101
|
"input_tokens": getattr(usage, "prompt_tokens", None) if usage else None,
|
|
45
102
|
"output_tokens": getattr(usage, "completion_tokens", None) if usage else None,
|
|
103
|
+
**_usage_detail(usage),
|
|
46
104
|
"error": content_fetch_error is not None,
|
|
47
105
|
"error_type": (
|
|
48
106
|
type(content_fetch_error).__name__ if content_fetch_error else None
|
|
@@ -52,10 +110,13 @@ def normalize_completion(
|
|
|
52
110
|
"route": route,
|
|
53
111
|
"content_opted_in": content_opted_in,
|
|
54
112
|
"request_json": request_json,
|
|
55
|
-
"response_text":
|
|
113
|
+
"response_text": capture_response_text(
|
|
114
|
+
response_text, content_opted_in=content_opted_in
|
|
115
|
+
),
|
|
116
|
+
"tool_calls": tool_calls,
|
|
56
117
|
"sdk": "metergraphrelay",
|
|
57
118
|
"sdk_version": __version__,
|
|
58
|
-
}
|
|
119
|
+
})
|
|
59
120
|
|
|
60
121
|
|
|
61
122
|
def pull_openai(
|
|
@@ -34,6 +34,7 @@ from datetime import datetime
|
|
|
34
34
|
from typing import Any, Callable
|
|
35
35
|
|
|
36
36
|
from .. import __version__
|
|
37
|
+
from ..capture_contract import capture_response_text, capture_row
|
|
37
38
|
from ..window import normalize_utc_designator
|
|
38
39
|
from ..import_identity import ImportContext, canonical_import_event_id
|
|
39
40
|
|
|
@@ -383,7 +384,7 @@ def normalize_span(
|
|
|
383
384
|
"content_opted_in": True,
|
|
384
385
|
"request_json": request_json,
|
|
385
386
|
"request_text": request_text,
|
|
386
|
-
"response_text": _response_text(attributes),
|
|
387
|
+
"response_text": capture_response_text(_response_text(attributes)),
|
|
387
388
|
"tool_names": _tool_names(attributes),
|
|
388
389
|
"trace_id": context.get("trace_id"),
|
|
389
390
|
"span_id": span_id,
|
|
@@ -395,7 +396,7 @@ def normalize_span(
|
|
|
395
396
|
row["import_source"] = import_context.source
|
|
396
397
|
row["import_source_scope"] = import_context.source_scope
|
|
397
398
|
row["import_event_id"] = canonical_import_event_id(span_id)
|
|
398
|
-
return row
|
|
399
|
+
return capture_row(row)
|
|
399
400
|
|
|
400
401
|
|
|
401
402
|
def _cleanup_temp_file(tmp_path: str) -> None:
|
|
@@ -5,11 +5,13 @@ import math
|
|
|
5
5
|
import re
|
|
6
6
|
import sys
|
|
7
7
|
from dataclasses import dataclass
|
|
8
|
+
from decimal import Decimal
|
|
8
9
|
from datetime import datetime, timedelta, timezone
|
|
9
10
|
from typing import Any, Callable
|
|
10
11
|
|
|
11
12
|
from .. import __version__
|
|
12
|
-
from ..
|
|
13
|
+
from ..billing_evidence import reported_cost
|
|
14
|
+
from ..capture_contract import capture_row, capture_text, capture_tool_calls
|
|
13
15
|
|
|
14
16
|
|
|
15
17
|
# Shared with the other sync providers; re-exported here for existing imports.
|
|
@@ -237,6 +239,198 @@ def _timestamp_from_epoch(numeric: float, raw: Any) -> datetime:
|
|
|
237
239
|
) from exc
|
|
238
240
|
|
|
239
241
|
|
|
242
|
+
# Portkey passes the upstream provider's usage block through untouched, so one
|
|
243
|
+
# export carries every vendor's spelling of the same counts. Each is resolved by
|
|
244
|
+
# path and the first present one wins. Reasoning tokens are already inside
|
|
245
|
+
# res_units, so they are detail only and never added to output_tokens.
|
|
246
|
+
_CACHE_READ_PATHS = (
|
|
247
|
+
("input_tokens_details", "cached_tokens"),
|
|
248
|
+
("prompt_tokens_details", "cached_tokens"),
|
|
249
|
+
("cache_read_input_tokens",),
|
|
250
|
+
)
|
|
251
|
+
_CACHE_WRITE_PATHS = (
|
|
252
|
+
("cache_creation_input_tokens",),
|
|
253
|
+
("input_tokens_details", "cache_write_tokens"),
|
|
254
|
+
)
|
|
255
|
+
# Some responses carry the TTL split in place of the aggregate, so summing the
|
|
256
|
+
# split is the only way to see the total.
|
|
257
|
+
_CACHE_WRITE_5M_PATH = ("cache_creation", "ephemeral_5m_input_tokens")
|
|
258
|
+
_CACHE_WRITE_1H_PATH = ("cache_creation", "ephemeral_1h_input_tokens")
|
|
259
|
+
_CACHE_WRITE_TTL_PATHS = (_CACHE_WRITE_5M_PATH, _CACHE_WRITE_1H_PATH)
|
|
260
|
+
_REASONING_PATHS = (
|
|
261
|
+
("output_tokens_details", "reasoning_tokens"),
|
|
262
|
+
("completion_tokens_details", "reasoning_tokens"),
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _is_int(value: Any) -> bool:
|
|
267
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _dig(source: Any, path: tuple[str, ...]) -> Any:
|
|
271
|
+
"""Follow a key path through nested dicts, returning None off the path.
|
|
272
|
+
|
|
273
|
+
The usage block is provider-supplied and reaches us through an error path as
|
|
274
|
+
readily as a success one, so any level may be missing or not a dict.
|
|
275
|
+
"""
|
|
276
|
+
current = source
|
|
277
|
+
for key in path:
|
|
278
|
+
if not isinstance(current, dict):
|
|
279
|
+
return None
|
|
280
|
+
current = current.get(key)
|
|
281
|
+
return current
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _first_int(source: Any, paths: tuple[tuple[str, ...], ...]) -> int | None:
|
|
285
|
+
for path in paths:
|
|
286
|
+
value = _dig(source, path)
|
|
287
|
+
if _is_int(value):
|
|
288
|
+
return value
|
|
289
|
+
return None
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _first_present(source: Any, paths: tuple[tuple[str, ...], ...]) -> Any:
|
|
293
|
+
for path in paths:
|
|
294
|
+
value = _dig(source, path)
|
|
295
|
+
if value is not None:
|
|
296
|
+
return value
|
|
297
|
+
return None
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _sum_ints(source: Any, paths: tuple[tuple[str, ...], ...]) -> int | None:
|
|
301
|
+
values = [value for path in paths if _is_int(value := _dig(source, path))]
|
|
302
|
+
return sum(values) if values else None
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _usage_detail(response: dict) -> dict[str, Any]:
|
|
306
|
+
"""Usage detail from a Portkey row's response, present keys only.
|
|
307
|
+
|
|
308
|
+
Absent and zero price differently, so a detail nobody reported stays absent
|
|
309
|
+
rather than becoming a plausible-looking 0 that hides a capture regression.
|
|
310
|
+
"""
|
|
311
|
+
usage = response.get("usage")
|
|
312
|
+
detail: dict[str, Any] = {}
|
|
313
|
+
|
|
314
|
+
cache_read = _first_int(usage, _CACHE_READ_PATHS)
|
|
315
|
+
if cache_read is not None:
|
|
316
|
+
detail["cache_read_tokens"] = cache_read
|
|
317
|
+
|
|
318
|
+
write_5m = _dig(usage, _CACHE_WRITE_5M_PATH)
|
|
319
|
+
if _is_int(write_5m):
|
|
320
|
+
detail["cache_write_5m_tokens"] = write_5m
|
|
321
|
+
write_1h = _dig(usage, _CACHE_WRITE_1H_PATH)
|
|
322
|
+
if _is_int(write_1h):
|
|
323
|
+
detail["cache_write_1h_tokens"] = write_1h
|
|
324
|
+
|
|
325
|
+
cache_write = _first_int(usage, _CACHE_WRITE_PATHS)
|
|
326
|
+
if cache_write is None:
|
|
327
|
+
# Only the TTL split was reported: sum it so a consumer that reads just
|
|
328
|
+
# cache_write_tokens still sees every written token.
|
|
329
|
+
cache_write = _sum_ints(usage, _CACHE_WRITE_TTL_PATHS)
|
|
330
|
+
if cache_write is not None:
|
|
331
|
+
detail["cache_write_tokens"] = cache_write
|
|
332
|
+
|
|
333
|
+
reasoning = _first_int(usage, _REASONING_PATHS)
|
|
334
|
+
if reasoning is not None:
|
|
335
|
+
detail["reasoning_tokens"] = reasoning
|
|
336
|
+
|
|
337
|
+
# Premium tier and region are request metadata, not counts, and they are
|
|
338
|
+
# what makes priority/batch and regional pricing answerable at all. The
|
|
339
|
+
# ingest writer keeps unrecognised keys in calls.meta, so they survive
|
|
340
|
+
# without a schema change.
|
|
341
|
+
service_tier = _first_present(
|
|
342
|
+
{"response": response, "usage": usage},
|
|
343
|
+
(("response", "service_tier"), ("usage", "service_tier")),
|
|
344
|
+
)
|
|
345
|
+
if service_tier is not None:
|
|
346
|
+
detail["service_tier"] = service_tier
|
|
347
|
+
|
|
348
|
+
inference_geo = _dig(usage, ("inference_geo",))
|
|
349
|
+
if inference_geo is not None:
|
|
350
|
+
detail["inference_geo"] = inference_geo
|
|
351
|
+
|
|
352
|
+
server_tool_use = _dig(usage, ("server_tool_use",))
|
|
353
|
+
if isinstance(server_tool_use, dict) and server_tool_use:
|
|
354
|
+
detail["server_tool_use"] = server_tool_use
|
|
355
|
+
|
|
356
|
+
grounding_queries = _grounding_queries(response)
|
|
357
|
+
if grounding_queries is not None:
|
|
358
|
+
detail["grounding_queries"] = grounding_queries
|
|
359
|
+
|
|
360
|
+
searches = _web_search_calls(response)
|
|
361
|
+
if searches is not None:
|
|
362
|
+
detail["web_search_calls"] = searches
|
|
363
|
+
|
|
364
|
+
return detail
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _web_search_calls(response: dict) -> int | None:
|
|
368
|
+
"""How many searches the model ran, for providers that bill per search.
|
|
369
|
+
|
|
370
|
+
The count is in the output the model produced, not in `usage`, and a single
|
|
371
|
+
call runs several. It is a whole charge of its own: on this traffic it is
|
|
372
|
+
priced per search at a rate the token rates cannot express.
|
|
373
|
+
"""
|
|
374
|
+
output = response.get("output")
|
|
375
|
+
if not isinstance(output, list):
|
|
376
|
+
return None
|
|
377
|
+
return sum(
|
|
378
|
+
1
|
|
379
|
+
for item in output
|
|
380
|
+
if isinstance(item, dict) and item.get("type") == "web_search_call"
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _cost_usd_from_cents(cents: Any) -> Decimal | None:
|
|
385
|
+
"""A row's cost in dollars, or None when it does not state a usable one.
|
|
386
|
+
|
|
387
|
+
Portkey states the cost in cents. Dividing in decimal keeps the value exact:
|
|
388
|
+
it ends up in a numeric column, and binary rounding introduced here survives
|
|
389
|
+
the whole way.
|
|
390
|
+
|
|
391
|
+
A bool is an int in Python, and ``Decimal("True")`` raises an error the
|
|
392
|
+
export converter does not catch, so one such row would end the window rather
|
|
393
|
+
than convert without a cost. A non-finite value is not a cost either.
|
|
394
|
+
"""
|
|
395
|
+
if isinstance(cents, bool) or not isinstance(cents, (int, float)):
|
|
396
|
+
return None
|
|
397
|
+
value = Decimal(str(cents))
|
|
398
|
+
return value / 100 if value.is_finite() else None
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _endpoint(response: dict) -> str | None:
|
|
402
|
+
"""Which provider API the call went to, as the billing evidence names it.
|
|
403
|
+
|
|
404
|
+
A gateway's reported cost is only interpretable alongside the endpoint that
|
|
405
|
+
produced it, because the same gateway prices its endpoints differently.
|
|
406
|
+
"""
|
|
407
|
+
if response.get("object") == "response":
|
|
408
|
+
return "responses"
|
|
409
|
+
if isinstance(response.get("choices"), list):
|
|
410
|
+
return "chat.completions"
|
|
411
|
+
return None
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _grounding_queries(response: dict) -> int | None:
|
|
415
|
+
"""How many grounding queries a Gemini response ran.
|
|
416
|
+
|
|
417
|
+
Google bills grounding per query, not per prompt, and one prompt runs
|
|
418
|
+
several, so the count cannot be derived from the call count. It sits outside
|
|
419
|
+
`usage`, under each choice's groundingMetadata.
|
|
420
|
+
"""
|
|
421
|
+
choices = response.get("choices")
|
|
422
|
+
if not isinstance(choices, list):
|
|
423
|
+
return None
|
|
424
|
+
total = 0
|
|
425
|
+
seen = False
|
|
426
|
+
for choice in choices:
|
|
427
|
+
queries = _dig(choice, ("groundingMetadata", "webSearchQueries"))
|
|
428
|
+
if isinstance(queries, list):
|
|
429
|
+
seen = True
|
|
430
|
+
total += len(queries)
|
|
431
|
+
return total if seen else None
|
|
432
|
+
|
|
433
|
+
|
|
240
434
|
def normalize_portkey_row(
|
|
241
435
|
row: dict, *, import_context: ImportContext | None = None
|
|
242
436
|
) -> dict:
|
|
@@ -274,8 +468,7 @@ def normalize_portkey_row(
|
|
|
274
468
|
else "portkey/backfill"
|
|
275
469
|
)
|
|
276
470
|
|
|
277
|
-
|
|
278
|
-
cost_usd = cost / 100 if isinstance(cost, (int, float)) else None
|
|
471
|
+
cost_usd = _cost_usd_from_cents(row.get("cost"))
|
|
279
472
|
|
|
280
473
|
result = {
|
|
281
474
|
"ts": ts,
|
|
@@ -287,7 +480,15 @@ def normalize_portkey_row(
|
|
|
287
480
|
"latency_ms": row.get("response_time"),
|
|
288
481
|
"error": is_error,
|
|
289
482
|
"error_type": error_type,
|
|
290
|
-
|
|
483
|
+
# Portkey's own figure, named so it can be recognised as a gateway's
|
|
484
|
+
# amount rather than a number of unknown origin. `cost_usd` stays for
|
|
485
|
+
# application versions that only read the legacy field.
|
|
486
|
+
"cost_usd": str(cost_usd) if cost_usd is not None else None,
|
|
487
|
+
# Stated whether or not a cost came with the row: it is where the call
|
|
488
|
+
# was served, not a property of the amount.
|
|
489
|
+
"gateway": "portkey",
|
|
490
|
+
**reported_cost(cost_usd, source="portkey.cost"),
|
|
491
|
+
"endpoint": _endpoint(response),
|
|
291
492
|
"request_id": request_id,
|
|
292
493
|
"span_id": request_id,
|
|
293
494
|
"trace_id": trace_id,
|
|
@@ -301,11 +502,12 @@ def normalize_portkey_row(
|
|
|
301
502
|
"sdk_version": __version__,
|
|
302
503
|
"content_opted_in": True,
|
|
303
504
|
}
|
|
505
|
+
result.update(_usage_detail(response))
|
|
304
506
|
if import_context is not None:
|
|
305
507
|
result["import_source"] = import_context.source
|
|
306
508
|
result["import_source_scope"] = import_context.source_scope
|
|
307
509
|
result["import_event_id"] = import_event_id # validated, stripped id
|
|
308
|
-
return result
|
|
510
|
+
return capture_row(result)
|
|
309
511
|
|
|
310
512
|
|
|
311
513
|
def convert_portkey_export(
|
{metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/src/metergraphrelay/providers/portkey_export.py
RENAMED
|
@@ -195,7 +195,7 @@ class PortkeyExportClient:
|
|
|
195
195
|
return PortkeyExport(export_id=returned_id, total=None, status=status)
|
|
196
196
|
|
|
197
197
|
def cancel_export(self, export_id: str) -> None:
|
|
198
|
-
self._api_request("POST", self._export_path(export_id, "/cancel"), body=
|
|
198
|
+
self._api_request("POST", self._export_path(export_id, "/cancel"), body=None)
|
|
199
199
|
|
|
200
200
|
def download_to(
|
|
201
201
|
self, export_id: str, dest_path: str, *, on_progress: Callable[[], None] | None = None
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: metergraphrelay
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.2
|
|
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
|
|
@@ -551,6 +551,9 @@ records**, the run splits it **once** into **10 sub-windows with
|
|
|
551
551
|
absorbs the boundary overlaps). This split is one-shot, never recursive:
|
|
552
552
|
if any single sub-window *still* exceeds 50,000 records, the run fails
|
|
553
553
|
with a clear error rather than splitting further.
|
|
554
|
+
Before splitting, the run cancels the never-started hourly draft on a best effort
|
|
555
|
+
basis. If Portkey refuses the cancel, the run warns and continues because an
|
|
556
|
+
unstarted draft only counts rows and exports nothing.
|
|
554
557
|
|
|
555
558
|
**Before running this against your own data:** as in manual mode,
|
|
556
559
|
request and response content is uploaded to metergraph, with no opt-out.
|
|
@@ -2,6 +2,7 @@ LICENSE
|
|
|
2
2
|
README.md
|
|
3
3
|
pyproject.toml
|
|
4
4
|
src/metergraphrelay/__init__.py
|
|
5
|
+
src/metergraphrelay/billing_evidence.py
|
|
5
6
|
src/metergraphrelay/capture_contract.py
|
|
6
7
|
src/metergraphrelay/cli.py
|
|
7
8
|
src/metergraphrelay/config.py
|
|
@@ -28,6 +29,7 @@ src/metergraphrelay/providers/openai.py
|
|
|
28
29
|
src/metergraphrelay/providers/phoenix.py
|
|
29
30
|
src/metergraphrelay/providers/portkey.py
|
|
30
31
|
src/metergraphrelay/providers/portkey_export.py
|
|
32
|
+
tests/test_billing_evidence.py
|
|
31
33
|
tests/test_capture_contract.py
|
|
32
34
|
tests/test_cli.py
|
|
33
35
|
tests/test_config.py
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Tests for how an importer states where a cost figure came from."""
|
|
2
|
+
|
|
3
|
+
from decimal import Decimal
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from metergraphrelay.billing_evidence import reported_cost
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_an_amount_is_named_by_its_source():
|
|
11
|
+
assert reported_cost(0.0012, source="langfuse.observation.totalCost") == {
|
|
12
|
+
"reported_cost_usd": "0.0012",
|
|
13
|
+
"reported_cost_source": "langfuse.observation.totalCost",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_the_amount_keeps_every_digit_it_was_given():
|
|
18
|
+
"""A cost lands in a numeric column, so rounding introduced here survives
|
|
19
|
+
the whole way."""
|
|
20
|
+
assert reported_cost(
|
|
21
|
+
Decimal("19.394766285"), source="portkey.cost"
|
|
22
|
+
)["reported_cost_usd"] == "19.394766285"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.mark.parametrize("amount", [None, True, False, "", "abc", object()])
|
|
26
|
+
def test_a_source_is_never_named_without_an_amount_behind_it(amount):
|
|
27
|
+
"""A named source with no amount would read as a cost of nothing rather than
|
|
28
|
+
as no cost reported."""
|
|
29
|
+
assert reported_cost(amount, source="x.cost") == {}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.mark.parametrize("amount", [-1, "-0.01", float("inf"), float("nan")])
|
|
33
|
+
def test_an_amount_that_cannot_be_a_cost_is_refused(amount):
|
|
34
|
+
assert reported_cost(amount, source="x.cost") == {}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_zero_is_a_cost():
|
|
38
|
+
"""Nothing billed is a fact; it is not a missing figure."""
|
|
39
|
+
assert reported_cost(0, source="x.cost")["reported_cost_usd"] == "0"
|
|
@@ -15,6 +15,7 @@ import pytest
|
|
|
15
15
|
|
|
16
16
|
from metergraphrelay.capture_contract import (
|
|
17
17
|
NATIVE_SEARCH_AUDIT_TYPES,
|
|
18
|
+
capture_response_text,
|
|
18
19
|
capture_text,
|
|
19
20
|
capture_tool_call,
|
|
20
21
|
capture_tool_calls,
|
|
@@ -126,3 +127,33 @@ def test_no_surviving_tool_calls_is_none_not_an_empty_list():
|
|
|
126
127
|
assert capture_tool_calls([{"type": "reasoning", "id": "rs-1"}]) is None
|
|
127
128
|
assert capture_tool_calls([]) is None
|
|
128
129
|
assert capture_tool_calls(None) is None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_a_recognised_tool_call_with_no_id_is_identified_by_position():
|
|
133
|
+
# Providers that log whatever an integration handed them sometimes omit the
|
|
134
|
+
# id. Dropping the call would describe a tool turn as text alone.
|
|
135
|
+
calls = capture_tool_calls(
|
|
136
|
+
[
|
|
137
|
+
{"type": "text", "text": "ignored"},
|
|
138
|
+
{"type": "tool_use", "name": "search", "input": {}},
|
|
139
|
+
]
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
assert calls == [{"call_id": "tool-1", "name": "search", "arguments": "{}"}]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_a_tool_call_with_no_name_is_dropped_even_with_a_position():
|
|
146
|
+
assert capture_tool_calls([{"type": "tool_use", "id": "t-1"}]) is None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_capture_response_text_spells_a_text_less_reply_as_empty():
|
|
150
|
+
assert capture_response_text(None) == ""
|
|
151
|
+
assert capture_response_text("hi") == "hi"
|
|
152
|
+
assert capture_response_text({"a": 1}) == '{"a": 1}'
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_capture_response_text_leaves_an_opted_out_row_without_content():
|
|
156
|
+
# The pipeline reads None here as the content opt-out, which is the one
|
|
157
|
+
# place None is correct.
|
|
158
|
+
assert capture_response_text(None, content_opted_in=False) is None
|
|
159
|
+
assert capture_response_text("hi", content_opted_in=False) is None
|
|
@@ -413,6 +413,41 @@ def test_over_threshold_cancels_unstarted_hourly_draft_and_splits_into_ten():
|
|
|
413
413
|
assert mg.completed == ["lease-1"] # completed only after all ten pushed
|
|
414
414
|
|
|
415
415
|
|
|
416
|
+
def test_cancel_failure_on_unstarted_hourly_draft_does_not_abort_the_split(capsys):
|
|
417
|
+
full = (WINDOW_START, WINDOW_END)
|
|
418
|
+
|
|
419
|
+
class CancelFailure(FakePortkey):
|
|
420
|
+
def cancel_export(self, export_id):
|
|
421
|
+
self.cancelled.append(export_id)
|
|
422
|
+
raise PortkeyExportError(
|
|
423
|
+
"Portkey export request failed: HTTP 400 Bad Request"
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
pk = CancelFailure({}, totals={full: VOLUME_SPLIT_THRESHOLD + 1})
|
|
427
|
+
original_create = pk.create_export
|
|
428
|
+
|
|
429
|
+
def seeding_create(*, window_start, window_end):
|
|
430
|
+
pk._rows.setdefault((window_start, window_end), [_portkey_row(f"r-{window_start}")])
|
|
431
|
+
return original_create(window_start=window_start, window_end=window_end)
|
|
432
|
+
|
|
433
|
+
pk.create_export = seeding_create
|
|
434
|
+
mg = FakeMeterGraph(_acquired())
|
|
435
|
+
|
|
436
|
+
outcome = _run(mg, pk, [])
|
|
437
|
+
|
|
438
|
+
assert outcome.status == "completed"
|
|
439
|
+
assert outcome.exit_code == 0
|
|
440
|
+
assert pk.cancelled == ["exp-1"]
|
|
441
|
+
assert len(pk.created) == 1 + 10
|
|
442
|
+
assert pk.started == [f"exp-{i}" for i in range(2, 12)]
|
|
443
|
+
assert mg.completed == ["lease-1"]
|
|
444
|
+
assert mg.abandoned == []
|
|
445
|
+
stderr = capsys.readouterr().err
|
|
446
|
+
assert "Warning" in stderr
|
|
447
|
+
assert "exp-1" in stderr
|
|
448
|
+
assert "400" in stderr
|
|
449
|
+
|
|
450
|
+
|
|
416
451
|
def test_boundary_total_exactly_at_threshold_does_not_split():
|
|
417
452
|
# total == 50_000 is NOT over the threshold: the hourly draft is used as-is.
|
|
418
453
|
full = (WINDOW_START, WINDOW_END)
|
|
@@ -445,6 +480,30 @@ def test_subwindow_still_over_threshold_is_rejected_without_recursion():
|
|
|
445
480
|
assert str(VOLUME_SPLIT_THRESHOLD) in outcome.detail or "recursiv" in outcome.detail.lower()
|
|
446
481
|
|
|
447
482
|
|
|
483
|
+
def test_failed_hourly_cancel_is_not_retried_during_later_cleanup():
|
|
484
|
+
full = (WINDOW_START, WINDOW_END)
|
|
485
|
+
|
|
486
|
+
class CancelFailure(FakePortkey):
|
|
487
|
+
def cancel_export(self, export_id):
|
|
488
|
+
self.cancelled.append(export_id)
|
|
489
|
+
if export_id == "exp-1":
|
|
490
|
+
raise PortkeyExportError(
|
|
491
|
+
"Portkey export request failed: HTTP 400 Bad Request"
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
pk = CancelFailure({}, totals={full: VOLUME_SPLIT_THRESHOLD + 1},
|
|
495
|
+
default_total=VOLUME_SPLIT_THRESHOLD + 1)
|
|
496
|
+
mg = FakeMeterGraph(_acquired())
|
|
497
|
+
|
|
498
|
+
outcome = _run(mg, pk, [])
|
|
499
|
+
|
|
500
|
+
assert outcome.status == "failed"
|
|
501
|
+
assert outcome.exit_code == 1
|
|
502
|
+
assert mg.completed == []
|
|
503
|
+
assert mg.abandoned == ["lease-1"]
|
|
504
|
+
assert pk.cancelled.count("exp-1") == 1
|
|
505
|
+
|
|
506
|
+
|
|
448
507
|
def test_planning_valueerror_from_bad_window_bounds_is_handled_failure():
|
|
449
508
|
# The server hands out reversed window bounds and an oversized draft, so the
|
|
450
509
|
# split planner calls split_window with end <= start and it raises ValueError.
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.7.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/src/metergraphrelay.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/src/metergraphrelay.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|