metergraph 0.6.7__tar.gz → 0.6.9__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.
- {metergraph-0.6.7/src/metergraph.egg-info → metergraph-0.6.9}/PKG-INFO +9 -2
- {metergraph-0.6.7 → metergraph-0.6.9}/README.md +8 -1
- {metergraph-0.6.7 → metergraph-0.6.9}/pyproject.toml +1 -1
- {metergraph-0.6.7 → metergraph-0.6.9}/src/metergraph/_capture.py +270 -4
- {metergraph-0.6.7 → metergraph-0.6.9}/src/metergraph/_context.py +3 -0
- {metergraph-0.6.7 → metergraph-0.6.9}/src/metergraph/_genai_attrs.py +225 -28
- {metergraph-0.6.7 → metergraph-0.6.9}/src/metergraph/_template.py +75 -0
- metergraph-0.6.9/src/metergraph/_tool_definitions.py +481 -0
- {metergraph-0.6.7 → metergraph-0.6.9}/src/metergraph/_version.py +1 -1
- {metergraph-0.6.7 → metergraph-0.6.9}/src/metergraph/opentelemetry.py +13 -1
- {metergraph-0.6.7 → metergraph-0.6.9/src/metergraph.egg-info}/PKG-INFO +9 -2
- {metergraph-0.6.7 → metergraph-0.6.9}/src/metergraph.egg-info/SOURCES.txt +1 -0
- {metergraph-0.6.7 → metergraph-0.6.9}/MANIFEST.in +0 -0
- {metergraph-0.6.7 → metergraph-0.6.9}/setup.cfg +0 -0
- {metergraph-0.6.7 → metergraph-0.6.9}/src/metergraph/__init__.py +0 -0
- {metergraph-0.6.7 → metergraph-0.6.9}/src/metergraph/_batch_first.py +0 -0
- {metergraph-0.6.7 → metergraph-0.6.9}/src/metergraph/_config.py +0 -0
- {metergraph-0.6.7 → metergraph-0.6.9}/src/metergraph/_failure_log.py +0 -0
- {metergraph-0.6.7 → metergraph-0.6.9}/src/metergraph/_gateway.py +0 -0
- {metergraph-0.6.7 → metergraph-0.6.9}/src/metergraph/_provider_batch.py +0 -0
- {metergraph-0.6.7 → metergraph-0.6.9}/src/metergraph/_repo_config.py +0 -0
- {metergraph-0.6.7 → metergraph-0.6.9}/src/metergraph/_session.py +0 -0
- {metergraph-0.6.7 → metergraph-0.6.9}/src/metergraph/_track.py +0 -0
- {metergraph-0.6.7 → metergraph-0.6.9}/src/metergraph/_transport.py +0 -0
- {metergraph-0.6.7 → metergraph-0.6.9}/src/metergraph.egg-info/dependency_links.txt +0 -0
- {metergraph-0.6.7 → metergraph-0.6.9}/src/metergraph.egg-info/requires.txt +0 -0
- {metergraph-0.6.7 → metergraph-0.6.9}/src/metergraph.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: metergraph
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.9
|
|
4
4
|
Summary: Fire-and-forget LLM spend capture for Metergraph
|
|
5
5
|
Author: Pioneer Square Labs
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -137,12 +137,19 @@ part types retain their model, usage, timing, and status metadata, but those
|
|
|
137
137
|
parts are not replayable yet. See the runnable
|
|
138
138
|
[`python-litellm-otel` example](../examples/python-litellm-otel/).
|
|
139
139
|
|
|
140
|
+
The exporter reads the OpenTelemetry `gen_ai.*` conventions, OpenInference
|
|
141
|
+
(Arize Phoenix), Langfuse and LangSmith spans. Dialects that label span kind
|
|
142
|
+
are honoured: a Langfuse observation that is not a `generation`, an
|
|
143
|
+
OpenInference span that is not `LLM`, and a LangSmith run whose
|
|
144
|
+
`langsmith.span.kind` is not `llm` are skipped as `"ineligible-kind"` rather
|
|
145
|
+
than counted as calls.
|
|
146
|
+
|
|
140
147
|
On a tracer provider shared with other instrumentation,
|
|
141
148
|
`MetergraphGenAIExporter(include_scopes=[...], exclude_scopes=[...])` filters on
|
|
142
149
|
`span.instrumentation_scope.name` (exclude wins; with `include_scopes` set, only
|
|
143
150
|
the listed scopes pass), so one call is never captured twice. The public
|
|
144
151
|
`exporter.skipped` dict counts spans that produced no row, keyed by reason
|
|
145
|
-
(`"scope"`, `"not-genai"`, `"no-model"`)
|
|
152
|
+
(`"scope"`, `"not-genai"`, `"ineligible-kind"`, `"no-model"`). The first thing to check when a tee
|
|
146
153
|
shows nothing.
|
|
147
154
|
|
|
148
155
|
Configuration:
|
|
@@ -112,12 +112,19 @@ part types retain their model, usage, timing, and status metadata, but those
|
|
|
112
112
|
parts are not replayable yet. See the runnable
|
|
113
113
|
[`python-litellm-otel` example](../examples/python-litellm-otel/).
|
|
114
114
|
|
|
115
|
+
The exporter reads the OpenTelemetry `gen_ai.*` conventions, OpenInference
|
|
116
|
+
(Arize Phoenix), Langfuse and LangSmith spans. Dialects that label span kind
|
|
117
|
+
are honoured: a Langfuse observation that is not a `generation`, an
|
|
118
|
+
OpenInference span that is not `LLM`, and a LangSmith run whose
|
|
119
|
+
`langsmith.span.kind` is not `llm` are skipped as `"ineligible-kind"` rather
|
|
120
|
+
than counted as calls.
|
|
121
|
+
|
|
115
122
|
On a tracer provider shared with other instrumentation,
|
|
116
123
|
`MetergraphGenAIExporter(include_scopes=[...], exclude_scopes=[...])` filters on
|
|
117
124
|
`span.instrumentation_scope.name` (exclude wins; with `include_scopes` set, only
|
|
118
125
|
the listed scopes pass), so one call is never captured twice. The public
|
|
119
126
|
`exporter.skipped` dict counts spans that produced no row, keyed by reason
|
|
120
|
-
(`"scope"`, `"not-genai"`, `"no-model"`)
|
|
127
|
+
(`"scope"`, `"not-genai"`, `"ineligible-kind"`, `"no-model"`). The first thing to check when a tee
|
|
121
128
|
shows nothing.
|
|
122
129
|
|
|
123
130
|
Configuration:
|
|
@@ -18,9 +18,17 @@ from pathlib import Path
|
|
|
18
18
|
from typing import Any, Callable, Mapping
|
|
19
19
|
from urllib.parse import urlsplit
|
|
20
20
|
|
|
21
|
+
from . import _tool_definitions
|
|
21
22
|
from ._context import CaptureContext, snapshot
|
|
22
23
|
from ._gateway import detect_gateway, gateway_evidence, resolve_gateway
|
|
23
|
-
from ._template import
|
|
24
|
+
from ._template import (
|
|
25
|
+
Unrepresentable,
|
|
26
|
+
json_equal,
|
|
27
|
+
json_value,
|
|
28
|
+
json_value_strict,
|
|
29
|
+
scrub_request,
|
|
30
|
+
template_hash,
|
|
31
|
+
)
|
|
24
32
|
from ._version import SDK_VERSION
|
|
25
33
|
|
|
26
34
|
|
|
@@ -361,6 +369,174 @@ def _response_content(response: Any, aggregate_text: str | None = None) -> Any:
|
|
|
361
369
|
return None
|
|
362
370
|
|
|
363
371
|
|
|
372
|
+
# Restrict tool-only detection to direct Anthropic response shapes.
|
|
373
|
+
_ANTHROPIC_ENDPOINTS = frozenset({"messages", "messages.stream"})
|
|
374
|
+
# Extra block fields would be lost when represented only by the canonical event.
|
|
375
|
+
_TOOL_BLOCK_KEYS = frozenset({"type", "id", "name", "input", "caller"})
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _block_mapping(block: Any) -> Mapping[str, Any] | None:
|
|
379
|
+
"""Return a response block as lossless JSON data."""
|
|
380
|
+
try:
|
|
381
|
+
converted = json_value_strict(block)
|
|
382
|
+
except Unrepresentable:
|
|
383
|
+
return None
|
|
384
|
+
return converted if isinstance(converted, Mapping) else None
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _representable_tool_block(block: Any) -> Mapping[str, Any] | None:
|
|
388
|
+
mapping = _block_mapping(block)
|
|
389
|
+
if mapping is None or mapping.get("type") != "tool_use":
|
|
390
|
+
return None
|
|
391
|
+
if set(mapping) - _TOOL_BLOCK_KEYS:
|
|
392
|
+
return None
|
|
393
|
+
caller = mapping.get("caller")
|
|
394
|
+
if caller is not None:
|
|
395
|
+
if not isinstance(caller, Mapping):
|
|
396
|
+
return None
|
|
397
|
+
declared = {key: value for key, value in caller.items() if value is not None}
|
|
398
|
+
if declared != {"type": "direct"}:
|
|
399
|
+
return None
|
|
400
|
+
return mapping
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _matched_events(
|
|
404
|
+
blocks: list[Mapping[str, Any]],
|
|
405
|
+
events: list[dict] | None,
|
|
406
|
+
*,
|
|
407
|
+
compare_arguments: bool = True,
|
|
408
|
+
) -> bool:
|
|
409
|
+
"""Check that every reply block has one equivalent event."""
|
|
410
|
+
ids = [block.get("id") for block in blocks]
|
|
411
|
+
if any(not isinstance(value, str) or not value for value in ids):
|
|
412
|
+
return False
|
|
413
|
+
if len(set(ids)) != len(ids):
|
|
414
|
+
return False
|
|
415
|
+
for block in blocks:
|
|
416
|
+
matches = [
|
|
417
|
+
event for event in (events or []) if event.get("call_id") == block["id"]
|
|
418
|
+
]
|
|
419
|
+
if len(matches) != 1:
|
|
420
|
+
return False
|
|
421
|
+
event = matches[0]
|
|
422
|
+
if event.get("name") != block.get("name"):
|
|
423
|
+
return False
|
|
424
|
+
# Streamed arguments are validated after delta accumulation.
|
|
425
|
+
if compare_arguments and not json_equal(
|
|
426
|
+
event.get("arguments"), _tool_argument(block.get("input"))
|
|
427
|
+
):
|
|
428
|
+
return False
|
|
429
|
+
return True
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _arguments_equal(accumulated: str, event_arguments: Any) -> bool:
|
|
433
|
+
try:
|
|
434
|
+
expected = json.loads(accumulated) if accumulated.strip() else {}
|
|
435
|
+
actual = event_arguments
|
|
436
|
+
if isinstance(actual, str):
|
|
437
|
+
actual = json.loads(actual) if actual.strip() else {}
|
|
438
|
+
elif actual is None:
|
|
439
|
+
actual = {}
|
|
440
|
+
except (json.JSONDecodeError, TypeError):
|
|
441
|
+
return False
|
|
442
|
+
return json_equal(expected, actual)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _stream_tool_only(chunks: list[Any], events: list[dict] | None) -> bool:
|
|
446
|
+
"""Check whether a completed Anthropic stream contains only tool calls."""
|
|
447
|
+
started: dict[str, Mapping[str, Any]] = {}
|
|
448
|
+
arguments: dict[str, str] = {}
|
|
449
|
+
stopped: set[str] = set()
|
|
450
|
+
stop_reasons: list[str] = []
|
|
451
|
+
saw_message_stop = False
|
|
452
|
+
for chunk in chunks:
|
|
453
|
+
if _chunk_text(chunk):
|
|
454
|
+
return False
|
|
455
|
+
kind = _get(chunk, "type")
|
|
456
|
+
delta = _get(chunk, "delta")
|
|
457
|
+
if isinstance(delta, str) and "reasoning" in str(kind):
|
|
458
|
+
return False
|
|
459
|
+
if _get(delta, "thinking") or _get(delta, "reasoning"):
|
|
460
|
+
return False
|
|
461
|
+
if kind == "content_block_start":
|
|
462
|
+
mapping = _representable_tool_block(_get(chunk, "content_block"))
|
|
463
|
+
if mapping is None:
|
|
464
|
+
return False
|
|
465
|
+
key = str(_get(chunk, "index", len(started)))
|
|
466
|
+
started[key] = mapping
|
|
467
|
+
initial = mapping.get("input")
|
|
468
|
+
arguments[key] = (
|
|
469
|
+
""
|
|
470
|
+
if initial in (None, {})
|
|
471
|
+
else json.dumps(initial, separators=(",", ":"))
|
|
472
|
+
)
|
|
473
|
+
elif kind == "content_block_delta":
|
|
474
|
+
if _get(delta, "type") != "input_json_delta":
|
|
475
|
+
return False
|
|
476
|
+
key = str(_get(chunk, "index", "0"))
|
|
477
|
+
arguments[key] = arguments.get(key, "") + str(
|
|
478
|
+
_get(delta, "partial_json") or ""
|
|
479
|
+
)
|
|
480
|
+
elif kind == "content_block_stop":
|
|
481
|
+
stopped.add(str(_get(chunk, "index", "0")))
|
|
482
|
+
elif kind == "message_delta":
|
|
483
|
+
reason = _get(delta, "stop_reason")
|
|
484
|
+
if reason is not None:
|
|
485
|
+
stop_reasons.append(str(reason))
|
|
486
|
+
elif kind == "message_stop":
|
|
487
|
+
saw_message_stop = True
|
|
488
|
+
if not started or not saw_message_stop:
|
|
489
|
+
return False
|
|
490
|
+
if set(started) - stopped:
|
|
491
|
+
return False
|
|
492
|
+
if any(reason != "tool_use" for reason in stop_reasons):
|
|
493
|
+
return False
|
|
494
|
+
blocks = list(started.values())
|
|
495
|
+
if not _matched_events(blocks, events, compare_arguments=False):
|
|
496
|
+
return False
|
|
497
|
+
for key, mapping in started.items():
|
|
498
|
+
matches = [
|
|
499
|
+
event for event in (events or []) if event.get("call_id") == mapping.get("id")
|
|
500
|
+
]
|
|
501
|
+
if len(matches) != 1 or not _arguments_equal(
|
|
502
|
+
arguments.get(key, ""), matches[0].get("arguments")
|
|
503
|
+
):
|
|
504
|
+
return False
|
|
505
|
+
return True
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _anthropic_tool_only(
|
|
509
|
+
response: Any,
|
|
510
|
+
*,
|
|
511
|
+
provider: str | None,
|
|
512
|
+
endpoint: str | None,
|
|
513
|
+
events: list[dict] | None,
|
|
514
|
+
aggregate_text: str | None,
|
|
515
|
+
stream_chunks: list[Any] | None,
|
|
516
|
+
completed: bool,
|
|
517
|
+
) -> bool:
|
|
518
|
+
"""Check for a losslessly represented Anthropic tool-only reply."""
|
|
519
|
+
if not completed or aggregate_text is not None:
|
|
520
|
+
return False
|
|
521
|
+
if provider != "anthropic" or endpoint not in _ANTHROPIC_ENDPOINTS:
|
|
522
|
+
return False
|
|
523
|
+
if not events:
|
|
524
|
+
return False
|
|
525
|
+
blocks = _get(response, "content")
|
|
526
|
+
if isinstance(blocks, list):
|
|
527
|
+
if not blocks:
|
|
528
|
+
return False
|
|
529
|
+
# Other stop reasons may indicate an unfinished tool call.
|
|
530
|
+
reason = _get(response, "stop_reason")
|
|
531
|
+
if reason is not None and str(reason) != "tool_use":
|
|
532
|
+
return False
|
|
533
|
+
mapped = [_representable_tool_block(block) for block in blocks]
|
|
534
|
+
if any(block is None for block in mapped):
|
|
535
|
+
return False
|
|
536
|
+
return _matched_events([block for block in mapped if block is not None], events)
|
|
537
|
+
return _stream_tool_only(list(stream_chunks or []), events)
|
|
538
|
+
|
|
539
|
+
|
|
364
540
|
def _response_envelope(
|
|
365
541
|
response: Any,
|
|
366
542
|
*,
|
|
@@ -368,10 +544,28 @@ def _response_envelope(
|
|
|
368
544
|
tool_calls: list[dict] | None,
|
|
369
545
|
error: BaseException | None,
|
|
370
546
|
status: str,
|
|
547
|
+
provider: str | None = None,
|
|
548
|
+
endpoint: str | None = None,
|
|
549
|
+
events: list[dict] | None = None,
|
|
550
|
+
stream_chunks: list[Any] | None = None,
|
|
551
|
+
completed: bool = False,
|
|
371
552
|
) -> dict[str, Any]:
|
|
553
|
+
tool_only = False
|
|
554
|
+
try:
|
|
555
|
+
tool_only = _anthropic_tool_only(
|
|
556
|
+
response,
|
|
557
|
+
provider=provider,
|
|
558
|
+
endpoint=endpoint,
|
|
559
|
+
events=events,
|
|
560
|
+
aggregate_text=aggregate_text,
|
|
561
|
+
stream_chunks=stream_chunks,
|
|
562
|
+
completed=completed,
|
|
563
|
+
)
|
|
564
|
+
except Exception:
|
|
565
|
+
tool_only = False
|
|
372
566
|
envelope: dict[str, Any] = {
|
|
373
567
|
"role": "assistant",
|
|
374
|
-
"content": _response_content(response, aggregate_text),
|
|
568
|
+
"content": None if tool_only else _response_content(response, aggregate_text),
|
|
375
569
|
"tool_calls": tool_calls or [],
|
|
376
570
|
"finish_reason": _stop_reason(response),
|
|
377
571
|
"request_id": _request_id(response),
|
|
@@ -385,7 +579,12 @@ def _response_envelope(
|
|
|
385
579
|
"type": type(error).__name__,
|
|
386
580
|
"message": str(error),
|
|
387
581
|
}
|
|
388
|
-
|
|
582
|
+
# Explicit null distinguishes tool-only output from missing content data.
|
|
583
|
+
return {
|
|
584
|
+
key: value
|
|
585
|
+
for key, value in envelope.items()
|
|
586
|
+
if value is not None or (tool_only and key == "content")
|
|
587
|
+
}
|
|
389
588
|
|
|
390
589
|
|
|
391
590
|
def _tool_names(request: Mapping[str, Any]) -> list[dict[str, str]] | None:
|
|
@@ -726,6 +925,9 @@ class Runtime:
|
|
|
726
925
|
value = self.options.redact(value, kind)
|
|
727
926
|
except Exception:
|
|
728
927
|
return "<redaction-failed>", False
|
|
928
|
+
# Invalid hook output must not drop the capture row.
|
|
929
|
+
if not isinstance(value, str):
|
|
930
|
+
return "<redaction-failed>", False
|
|
729
931
|
raw = value.encode()
|
|
730
932
|
if len(raw) <= self.options.text_max_bytes:
|
|
731
933
|
return value, False
|
|
@@ -735,6 +937,46 @@ class Runtime:
|
|
|
735
937
|
)
|
|
736
938
|
return clipped + marker, True
|
|
737
939
|
|
|
940
|
+
def _tool_definitions(
|
|
941
|
+
self, request: Mapping[str, Any], provider: str, *, enabled: bool
|
|
942
|
+
) -> tuple[dict[str, Any] | None, bool]:
|
|
943
|
+
"""Return redacted declarations and whether the size cap dropped them."""
|
|
944
|
+
if not enabled:
|
|
945
|
+
return None, False
|
|
946
|
+
# Optional declaration telemetry must not affect the provider call or row.
|
|
947
|
+
try:
|
|
948
|
+
return self._build_tool_definitions(request, provider)
|
|
949
|
+
except Exception:
|
|
950
|
+
return None, False
|
|
951
|
+
|
|
952
|
+
def _build_tool_definitions(
|
|
953
|
+
self, request: Mapping[str, Any], provider: str
|
|
954
|
+
) -> tuple[dict[str, Any] | None, bool]:
|
|
955
|
+
read = _tool_definitions.declarations(request, provider)
|
|
956
|
+
if read is None:
|
|
957
|
+
return None, False
|
|
958
|
+
records, scope = read
|
|
959
|
+
encoded = json.dumps(records, ensure_ascii=False, separators=(",", ":"))
|
|
960
|
+
fidelity = "verbatim"
|
|
961
|
+
if self.options.redact:
|
|
962
|
+
redacted = self.options.redact(encoded, "request")
|
|
963
|
+
if not isinstance(redacted, str):
|
|
964
|
+
return None, False
|
|
965
|
+
try:
|
|
966
|
+
parsed = json.loads(redacted)
|
|
967
|
+
except (TypeError, ValueError, RecursionError):
|
|
968
|
+
return None, False
|
|
969
|
+
if not _tool_definitions.valid_declarations(parsed):
|
|
970
|
+
return None, False
|
|
971
|
+
if not json_equal(parsed, records):
|
|
972
|
+
fidelity = "filtered"
|
|
973
|
+
records = parsed
|
|
974
|
+
envelope = _tool_definitions.envelope(records, scope, fidelity)
|
|
975
|
+
serialized = json.dumps(envelope, ensure_ascii=False, separators=(",", ":"))
|
|
976
|
+
if len(serialized.encode()) > self.options.text_max_bytes:
|
|
977
|
+
return None, True
|
|
978
|
+
return envelope, False
|
|
979
|
+
|
|
738
980
|
|
|
739
981
|
@dataclass
|
|
740
982
|
class CallState:
|
|
@@ -808,6 +1050,11 @@ class CallState:
|
|
|
808
1050
|
if full_tool_calls
|
|
809
1051
|
else None
|
|
810
1052
|
)
|
|
1053
|
+
tool_definitions_value, tool_definitions_truncated = (
|
|
1054
|
+
self.runtime._tool_definitions(
|
|
1055
|
+
self.request, self.provider, enabled=capture_text
|
|
1056
|
+
)
|
|
1057
|
+
)
|
|
811
1058
|
effective_status = status or (
|
|
812
1059
|
"error" if error else _stop_reason(response) or "success"
|
|
813
1060
|
)
|
|
@@ -825,6 +1072,16 @@ class CallState:
|
|
|
825
1072
|
tool_calls=full_tool_calls if capture_text else None,
|
|
826
1073
|
error=error,
|
|
827
1074
|
status=effective_status,
|
|
1075
|
+
provider=self.provider,
|
|
1076
|
+
endpoint=self.endpoint,
|
|
1077
|
+
events=full_tool_calls,
|
|
1078
|
+
stream_chunks=stream_chunks,
|
|
1079
|
+
completed=(
|
|
1080
|
+
error is None
|
|
1081
|
+
and status not in {"error", "abandoned"}
|
|
1082
|
+
and status_code != "error"
|
|
1083
|
+
and finish_reason != "error"
|
|
1084
|
+
),
|
|
828
1085
|
),
|
|
829
1086
|
ensure_ascii=False,
|
|
830
1087
|
separators=(",", ":"),
|
|
@@ -836,6 +1093,7 @@ class CallState:
|
|
|
836
1093
|
row: dict[str, Any] = {
|
|
837
1094
|
"ts": self.ts,
|
|
838
1095
|
"route": self.context.route,
|
|
1096
|
+
"route_source": self.context.route_source if self.context.route else None,
|
|
839
1097
|
"provider": self.provider,
|
|
840
1098
|
"model": self.request.get("model"),
|
|
841
1099
|
**_usage(response),
|
|
@@ -865,7 +1123,12 @@ class CallState:
|
|
|
865
1123
|
"content_opted_in": capture_text,
|
|
866
1124
|
"request_json": request_json,
|
|
867
1125
|
"response_text": response_json,
|
|
868
|
-
"text_truncated":
|
|
1126
|
+
"text_truncated": (
|
|
1127
|
+
request_truncated
|
|
1128
|
+
or response_truncated
|
|
1129
|
+
or tool_truncated
|
|
1130
|
+
or tool_definitions_truncated
|
|
1131
|
+
),
|
|
869
1132
|
"stream": stream,
|
|
870
1133
|
"ttft_ms": ttft_ms,
|
|
871
1134
|
"func": self.func,
|
|
@@ -879,6 +1142,9 @@ class CallState:
|
|
|
879
1142
|
"sdk_version": SDK_VERSION,
|
|
880
1143
|
"runtime": f"{platform.python_implementation().lower()}-{platform.python_version()}",
|
|
881
1144
|
}
|
|
1145
|
+
# Absence distinguishes tool-free calls from invalid declarations.
|
|
1146
|
+
if tool_definitions_value is not None:
|
|
1147
|
+
row["tool_definitions"] = tool_definitions_value
|
|
882
1148
|
try:
|
|
883
1149
|
self.runtime.writer.enqueue(row)
|
|
884
1150
|
except Exception:
|
|
@@ -15,6 +15,8 @@ from typing import Any, Callable, Mapping
|
|
|
15
15
|
@dataclass(frozen=True)
|
|
16
16
|
class CaptureContext:
|
|
17
17
|
route: str | None = None
|
|
18
|
+
# "explicit" when the developer named the route, "derived" when filled in.
|
|
19
|
+
route_source: str | None = None
|
|
18
20
|
session_id: str | None = None
|
|
19
21
|
tags: Mapping[str, str] = field(default_factory=dict)
|
|
20
22
|
unit_name: str | None = None
|
|
@@ -153,6 +155,7 @@ class route:
|
|
|
153
155
|
replace(
|
|
154
156
|
current,
|
|
155
157
|
route=self.name,
|
|
158
|
+
route_source="explicit",
|
|
156
159
|
tags=merged,
|
|
157
160
|
unit_name=self.unit if self.unit is not None else current.unit_name,
|
|
158
161
|
unit_count=self.unit_count
|