braintrust 0.5.0__py3-none-any.whl → 0.5.3__py3-none-any.whl
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.
- braintrust/__init__.py +14 -0
- braintrust/_generated_types.py +56 -3
- braintrust/auto.py +179 -0
- braintrust/conftest.py +23 -4
- braintrust/db_fields.py +10 -0
- braintrust/framework.py +18 -5
- braintrust/generated_types.py +3 -1
- braintrust/logger.py +369 -134
- braintrust/merge_row_batch.py +49 -109
- braintrust/oai.py +51 -0
- braintrust/test_bt_json.py +0 -5
- braintrust/test_context.py +1264 -0
- braintrust/test_framework.py +37 -0
- braintrust/test_http.py +444 -0
- braintrust/test_logger.py +179 -5
- braintrust/test_merge_row_batch.py +160 -0
- braintrust/test_util.py +58 -1
- braintrust/util.py +20 -0
- braintrust/version.py +2 -2
- braintrust/wrappers/agno/__init__.py +2 -3
- braintrust/wrappers/anthropic.py +64 -0
- braintrust/wrappers/claude_agent_sdk/__init__.py +2 -3
- braintrust/wrappers/claude_agent_sdk/test_wrapper.py +9 -0
- braintrust/wrappers/dspy.py +52 -1
- braintrust/wrappers/google_genai/__init__.py +9 -6
- braintrust/wrappers/litellm.py +6 -43
- braintrust/wrappers/pydantic_ai.py +2 -3
- braintrust/wrappers/test_agno.py +9 -0
- braintrust/wrappers/test_anthropic.py +156 -0
- braintrust/wrappers/test_dspy.py +117 -0
- braintrust/wrappers/test_google_genai.py +9 -0
- braintrust/wrappers/test_litellm.py +57 -55
- braintrust/wrappers/test_openai.py +253 -1
- braintrust/wrappers/test_pydantic_ai_integration.py +9 -0
- braintrust/wrappers/test_utils.py +79 -0
- braintrust/wrappers/threads.py +114 -0
- {braintrust-0.5.0.dist-info → braintrust-0.5.3.dist-info}/METADATA +1 -1
- {braintrust-0.5.0.dist-info → braintrust-0.5.3.dist-info}/RECORD +41 -37
- {braintrust-0.5.0.dist-info → braintrust-0.5.3.dist-info}/WHEEL +1 -1
- braintrust/graph_util.py +0 -147
- {braintrust-0.5.0.dist-info → braintrust-0.5.3.dist-info}/entry_points.txt +0 -0
- {braintrust-0.5.0.dist-info → braintrust-0.5.3.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
from braintrust.db_fields import IS_MERGE_FIELD
|
|
4
|
+
from braintrust.merge_row_batch import batch_items, merge_row_batch
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class MergeRowBatchTest(unittest.TestCase):
|
|
8
|
+
def test_basic(self):
|
|
9
|
+
rows = [
|
|
10
|
+
# These rows should get merged together, ending up as a merge.
|
|
11
|
+
dict(
|
|
12
|
+
experiment_id="e0",
|
|
13
|
+
id="x",
|
|
14
|
+
inputs=dict(a=12),
|
|
15
|
+
**{IS_MERGE_FIELD: True},
|
|
16
|
+
),
|
|
17
|
+
dict(
|
|
18
|
+
experiment_id="e0",
|
|
19
|
+
id="x",
|
|
20
|
+
inputs=dict(b=10),
|
|
21
|
+
**{IS_MERGE_FIELD: True},
|
|
22
|
+
),
|
|
23
|
+
dict(
|
|
24
|
+
experiment_id="e0",
|
|
25
|
+
id="x",
|
|
26
|
+
inputs=dict(c="hello"),
|
|
27
|
+
**{IS_MERGE_FIELD: True},
|
|
28
|
+
),
|
|
29
|
+
# The first row should be clobbered by the second, but the third
|
|
30
|
+
# merged with the second, ending up as a replacement.
|
|
31
|
+
dict(
|
|
32
|
+
experiment_id="e0",
|
|
33
|
+
id="y",
|
|
34
|
+
inputs=dict(a="hello"),
|
|
35
|
+
),
|
|
36
|
+
dict(
|
|
37
|
+
experiment_id="e0",
|
|
38
|
+
id="y",
|
|
39
|
+
inputs=dict(b=10),
|
|
40
|
+
),
|
|
41
|
+
dict(
|
|
42
|
+
experiment_id="e0",
|
|
43
|
+
id="y",
|
|
44
|
+
inputs=dict(c=12),
|
|
45
|
+
**{IS_MERGE_FIELD: True},
|
|
46
|
+
),
|
|
47
|
+
# These rows should be clobbered separately from the last batch.
|
|
48
|
+
dict(
|
|
49
|
+
dataset_id="d0",
|
|
50
|
+
id="y",
|
|
51
|
+
inputs=dict(a="hello"),
|
|
52
|
+
),
|
|
53
|
+
dict(
|
|
54
|
+
dataset_id="d0",
|
|
55
|
+
id="y",
|
|
56
|
+
inputs=dict(b=10),
|
|
57
|
+
),
|
|
58
|
+
dict(
|
|
59
|
+
dataset_id="d0",
|
|
60
|
+
id="y",
|
|
61
|
+
inputs=dict(c=12),
|
|
62
|
+
),
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
merged_rows = merge_row_batch(rows)
|
|
66
|
+
key_to_rows = {(row.get("experiment_id"), row.get("dataset_id"), row.get("id")): row for row in merged_rows}
|
|
67
|
+
self.assertEqual(
|
|
68
|
+
{
|
|
69
|
+
("e0", None, "x"): dict(
|
|
70
|
+
experiment_id="e0",
|
|
71
|
+
id="x",
|
|
72
|
+
inputs=dict(a=12, b=10, c="hello"),
|
|
73
|
+
**{IS_MERGE_FIELD: True},
|
|
74
|
+
),
|
|
75
|
+
("e0", None, "y"): dict(
|
|
76
|
+
experiment_id="e0",
|
|
77
|
+
id="y",
|
|
78
|
+
inputs=dict(b=10, c=12),
|
|
79
|
+
),
|
|
80
|
+
(None, "d0", "y"): dict(
|
|
81
|
+
dataset_id="d0",
|
|
82
|
+
id="y",
|
|
83
|
+
inputs=dict(c=12),
|
|
84
|
+
),
|
|
85
|
+
},
|
|
86
|
+
key_to_rows,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def test_skip_fields(self):
|
|
90
|
+
rows = [
|
|
91
|
+
# These rows should get merged together, ending up as a merge. But
|
|
92
|
+
# the original fields should be retained, regardless of whether we
|
|
93
|
+
# populated them or not.
|
|
94
|
+
dict(
|
|
95
|
+
experiment_id="e0",
|
|
96
|
+
id="x",
|
|
97
|
+
inputs=dict(a=12),
|
|
98
|
+
**{IS_MERGE_FIELD: True},
|
|
99
|
+
created=123,
|
|
100
|
+
root_span_id="abc",
|
|
101
|
+
_parent_id="baz",
|
|
102
|
+
span_parents=["foo", "bar"],
|
|
103
|
+
),
|
|
104
|
+
dict(
|
|
105
|
+
experiment_id="e0",
|
|
106
|
+
id="x",
|
|
107
|
+
inputs=dict(b=10),
|
|
108
|
+
**{IS_MERGE_FIELD: True},
|
|
109
|
+
created=456,
|
|
110
|
+
span_id="foo",
|
|
111
|
+
root_span_id="bar",
|
|
112
|
+
_parent_id="boop",
|
|
113
|
+
span_parents=[],
|
|
114
|
+
),
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
merged_rows = merge_row_batch(rows)
|
|
118
|
+
self.assertEqual(
|
|
119
|
+
merged_rows,
|
|
120
|
+
[
|
|
121
|
+
dict(
|
|
122
|
+
experiment_id="e0",
|
|
123
|
+
id="x",
|
|
124
|
+
inputs=dict(a=12, b=10),
|
|
125
|
+
**{IS_MERGE_FIELD: True},
|
|
126
|
+
created=123,
|
|
127
|
+
root_span_id="abc",
|
|
128
|
+
_parent_id="baz",
|
|
129
|
+
span_parents=["foo", "bar"],
|
|
130
|
+
),
|
|
131
|
+
],
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class BatchItemsTest(unittest.TestCase):
|
|
136
|
+
def test_basic(self):
|
|
137
|
+
a = "x" * 1
|
|
138
|
+
b = "x" * 2
|
|
139
|
+
c = "x" * 4
|
|
140
|
+
d = "y" * 1
|
|
141
|
+
e = "y" * 2
|
|
142
|
+
f = "y" * 4
|
|
143
|
+
|
|
144
|
+
items = [a, b, c, f, e, d]
|
|
145
|
+
|
|
146
|
+
# No limits.
|
|
147
|
+
output = batch_items(items)
|
|
148
|
+
self.assertEqual(output, [[a, b, c, f, e, d]])
|
|
149
|
+
|
|
150
|
+
# Num items limit.
|
|
151
|
+
output = batch_items(items, batch_max_num_items=2)
|
|
152
|
+
self.assertEqual(output, [[a, b], [c, f], [e, d]])
|
|
153
|
+
|
|
154
|
+
# Num bytes limit.
|
|
155
|
+
output = batch_items(items, batch_max_num_bytes=2)
|
|
156
|
+
self.assertEqual(output, [[a], [b], [c], [f], [e], [d]])
|
|
157
|
+
|
|
158
|
+
# Both items and num bytes limit.
|
|
159
|
+
output = batch_items(items, batch_max_num_items=2, batch_max_num_bytes=5)
|
|
160
|
+
self.assertEqual(output, [[a, b], [c], [f], [e, d]])
|
braintrust/test_util.py
CHANGED
|
@@ -1,9 +1,66 @@
|
|
|
1
|
+
import os
|
|
1
2
|
import unittest
|
|
2
3
|
from typing import List
|
|
3
4
|
|
|
4
5
|
import pytest
|
|
5
6
|
|
|
6
|
-
from .util import LazyValue, mask_api_key, merge_dicts_with_paths
|
|
7
|
+
from .util import LazyValue, mask_api_key, merge_dicts_with_paths, parse_env_var_float
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TestParseEnvVarFloat:
|
|
11
|
+
"""Tests for parse_env_var_float helper."""
|
|
12
|
+
|
|
13
|
+
def test_returns_default_when_env_not_set(self):
|
|
14
|
+
assert parse_env_var_float("NONEXISTENT_VAR_12345", 42.0) == 42.0
|
|
15
|
+
|
|
16
|
+
def test_parses_valid_float(self):
|
|
17
|
+
os.environ["TEST_FLOAT"] = "123.45"
|
|
18
|
+
try:
|
|
19
|
+
assert parse_env_var_float("TEST_FLOAT", 0.0) == 123.45
|
|
20
|
+
finally:
|
|
21
|
+
del os.environ["TEST_FLOAT"]
|
|
22
|
+
|
|
23
|
+
def test_returns_default_for_nan(self):
|
|
24
|
+
os.environ["TEST_FLOAT"] = "nan"
|
|
25
|
+
try:
|
|
26
|
+
assert parse_env_var_float("TEST_FLOAT", 99.0) == 99.0
|
|
27
|
+
finally:
|
|
28
|
+
del os.environ["TEST_FLOAT"]
|
|
29
|
+
|
|
30
|
+
def test_returns_default_for_inf(self):
|
|
31
|
+
os.environ["TEST_FLOAT"] = "inf"
|
|
32
|
+
try:
|
|
33
|
+
assert parse_env_var_float("TEST_FLOAT", 99.0) == 99.0
|
|
34
|
+
finally:
|
|
35
|
+
del os.environ["TEST_FLOAT"]
|
|
36
|
+
|
|
37
|
+
def test_returns_default_for_negative_inf(self):
|
|
38
|
+
os.environ["TEST_FLOAT"] = "-inf"
|
|
39
|
+
try:
|
|
40
|
+
assert parse_env_var_float("TEST_FLOAT", 99.0) == 99.0
|
|
41
|
+
finally:
|
|
42
|
+
del os.environ["TEST_FLOAT"]
|
|
43
|
+
|
|
44
|
+
def test_returns_default_for_empty_string(self):
|
|
45
|
+
os.environ["TEST_FLOAT"] = ""
|
|
46
|
+
try:
|
|
47
|
+
assert parse_env_var_float("TEST_FLOAT", 99.0) == 99.0
|
|
48
|
+
finally:
|
|
49
|
+
del os.environ["TEST_FLOAT"]
|
|
50
|
+
|
|
51
|
+
def test_returns_default_for_invalid_string(self):
|
|
52
|
+
os.environ["TEST_FLOAT"] = "not_a_number"
|
|
53
|
+
try:
|
|
54
|
+
assert parse_env_var_float("TEST_FLOAT", 99.0) == 99.0
|
|
55
|
+
finally:
|
|
56
|
+
del os.environ["TEST_FLOAT"]
|
|
57
|
+
|
|
58
|
+
def test_allows_negative_values(self):
|
|
59
|
+
os.environ["TEST_FLOAT"] = "-5.5"
|
|
60
|
+
try:
|
|
61
|
+
assert parse_env_var_float("TEST_FLOAT", 0.0) == -5.5
|
|
62
|
+
finally:
|
|
63
|
+
del os.environ["TEST_FLOAT"]
|
|
7
64
|
|
|
8
65
|
|
|
9
66
|
class TestLazyValue(unittest.TestCase):
|
braintrust/util.py
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import inspect
|
|
2
2
|
import json
|
|
3
|
+
import math
|
|
4
|
+
import os
|
|
3
5
|
import sys
|
|
4
6
|
import threading
|
|
5
7
|
import urllib.parse
|
|
@@ -9,6 +11,24 @@ from typing import Any, Generic, Literal, TypedDict, TypeVar, Union
|
|
|
9
11
|
|
|
10
12
|
from requests import HTTPError, Response
|
|
11
13
|
|
|
14
|
+
|
|
15
|
+
def parse_env_var_float(name: str, default: float) -> float:
|
|
16
|
+
"""Parse a float from an environment variable, returning default if invalid.
|
|
17
|
+
|
|
18
|
+
Returns the default value if the env var is missing, empty, not a valid
|
|
19
|
+
float, NaN, or infinity.
|
|
20
|
+
"""
|
|
21
|
+
value = os.environ.get(name)
|
|
22
|
+
if value is None:
|
|
23
|
+
return default
|
|
24
|
+
try:
|
|
25
|
+
result = float(value)
|
|
26
|
+
if math.isnan(result) or math.isinf(result):
|
|
27
|
+
return default
|
|
28
|
+
return result
|
|
29
|
+
except (ValueError, TypeError):
|
|
30
|
+
return default
|
|
31
|
+
|
|
12
32
|
GLOBAL_PROJECT = "Global"
|
|
13
33
|
BT_IS_ASYNC_ATTRIBUTE = "_BT_IS_ASYNC"
|
|
14
34
|
|
braintrust/version.py
CHANGED
|
@@ -62,7 +62,6 @@ def setup_agno(
|
|
|
62
62
|
models.base.Model = wrap_model(models.base.Model) # pyright: ignore[reportUnknownMemberType]
|
|
63
63
|
tools.function.FunctionCall = wrap_function_call(tools.function.FunctionCall) # pyright: ignore[reportUnknownMemberType]
|
|
64
64
|
return True
|
|
65
|
-
except ImportError
|
|
66
|
-
|
|
67
|
-
logger.error("Agno is not installed. Please install it with: pip install agno")
|
|
65
|
+
except ImportError:
|
|
66
|
+
# Not installed - this is expected when using auto_instrument()
|
|
68
67
|
return False
|
braintrust/wrappers/anthropic.py
CHANGED
|
@@ -5,6 +5,7 @@ from contextlib import contextmanager
|
|
|
5
5
|
|
|
6
6
|
from braintrust.logger import NOOP_SPAN, log_exc_info_to_span, start_span
|
|
7
7
|
from braintrust.wrappers._anthropic_utils import Wrapper, extract_anthropic_usage, finalize_anthropic_tokens
|
|
8
|
+
from wrapt import wrap_function_wrapper
|
|
8
9
|
|
|
9
10
|
log = logging.getLogger(__name__)
|
|
10
11
|
|
|
@@ -358,3 +359,66 @@ def wrap_anthropic(client):
|
|
|
358
359
|
|
|
359
360
|
def wrap_anthropic_client(client):
|
|
360
361
|
return wrap_anthropic(client)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _apply_anthropic_wrapper(client):
|
|
365
|
+
"""Apply tracing wrapper to an Anthropic client instance in-place."""
|
|
366
|
+
wrapped = wrap_anthropic(client)
|
|
367
|
+
client.messages = wrapped.messages
|
|
368
|
+
if hasattr(wrapped, "beta"):
|
|
369
|
+
client.beta = wrapped.beta
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _apply_async_anthropic_wrapper(client):
|
|
373
|
+
"""Apply tracing wrapper to an AsyncAnthropic client instance in-place."""
|
|
374
|
+
wrapped = wrap_anthropic(client)
|
|
375
|
+
client.messages = wrapped.messages
|
|
376
|
+
if hasattr(wrapped, "beta"):
|
|
377
|
+
client.beta = wrapped.beta
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _anthropic_init_wrapper(wrapped, instance, args, kwargs):
|
|
381
|
+
"""Wrapper for Anthropic.__init__ that applies tracing after initialization."""
|
|
382
|
+
wrapped(*args, **kwargs)
|
|
383
|
+
_apply_anthropic_wrapper(instance)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _async_anthropic_init_wrapper(wrapped, instance, args, kwargs):
|
|
387
|
+
"""Wrapper for AsyncAnthropic.__init__ that applies tracing after initialization."""
|
|
388
|
+
wrapped(*args, **kwargs)
|
|
389
|
+
_apply_async_anthropic_wrapper(instance)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def patch_anthropic() -> bool:
|
|
393
|
+
"""
|
|
394
|
+
Patch Anthropic to add Braintrust tracing globally.
|
|
395
|
+
|
|
396
|
+
After calling this, all new Anthropic() and AsyncAnthropic() clients
|
|
397
|
+
will automatically have tracing enabled.
|
|
398
|
+
|
|
399
|
+
Returns:
|
|
400
|
+
True if Anthropic was patched (or already patched), False if Anthropic is not installed.
|
|
401
|
+
|
|
402
|
+
Example:
|
|
403
|
+
```python
|
|
404
|
+
import braintrust
|
|
405
|
+
braintrust.patch_anthropic()
|
|
406
|
+
|
|
407
|
+
import anthropic
|
|
408
|
+
client = anthropic.Anthropic()
|
|
409
|
+
# All calls are now traced!
|
|
410
|
+
```
|
|
411
|
+
"""
|
|
412
|
+
try:
|
|
413
|
+
import anthropic
|
|
414
|
+
|
|
415
|
+
if getattr(anthropic, "__braintrust_wrapped__", False):
|
|
416
|
+
return True # Already patched
|
|
417
|
+
|
|
418
|
+
wrap_function_wrapper("anthropic", "Anthropic.__init__", _anthropic_init_wrapper)
|
|
419
|
+
wrap_function_wrapper("anthropic", "AsyncAnthropic.__init__", _async_anthropic_init_wrapper)
|
|
420
|
+
anthropic.__braintrust_wrapped__ = True
|
|
421
|
+
return True
|
|
422
|
+
|
|
423
|
+
except ImportError:
|
|
424
|
+
return False
|
|
@@ -105,7 +105,6 @@ def setup_claude_agent_sdk(
|
|
|
105
105
|
setattr(module, "tool", wrapped_tool_fn)
|
|
106
106
|
|
|
107
107
|
return True
|
|
108
|
-
except ImportError
|
|
109
|
-
|
|
110
|
-
logger.error("claude-agent-sdk is not installed. Please install it with: pip install claude-agent-sdk")
|
|
108
|
+
except ImportError:
|
|
109
|
+
# Not installed - this is expected when using auto_instrument()
|
|
111
110
|
return False
|
|
@@ -23,6 +23,7 @@ from braintrust.wrappers.claude_agent_sdk._wrapper import (
|
|
|
23
23
|
_create_client_wrapper_class,
|
|
24
24
|
_create_tool_wrapper_class,
|
|
25
25
|
)
|
|
26
|
+
from braintrust.wrappers.test_utils import verify_autoinstrument_script
|
|
26
27
|
|
|
27
28
|
PROJECT_NAME = "test-claude-agent-sdk"
|
|
28
29
|
TEST_MODEL = "claude-haiku-4-5-20251001"
|
|
@@ -283,3 +284,11 @@ async def _multi_message_generator():
|
|
|
283
284
|
"""Generator yielding multiple messages."""
|
|
284
285
|
yield _make_message("Part 1")
|
|
285
286
|
yield _make_message("Part 2")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class TestAutoInstrumentClaudeAgentSDK:
|
|
290
|
+
"""Tests for auto_instrument() with Claude Agent SDK."""
|
|
291
|
+
|
|
292
|
+
def test_auto_instrument_claude_agent_sdk(self):
|
|
293
|
+
"""Test auto_instrument patches Claude Agent SDK and creates spans."""
|
|
294
|
+
verify_autoinstrument_script("test_auto_claude_agent_sdk.py")
|
braintrust/wrappers/dspy.py
CHANGED
|
@@ -51,6 +51,7 @@ from typing import Any
|
|
|
51
51
|
|
|
52
52
|
from braintrust.logger import current_span, start_span
|
|
53
53
|
from braintrust.span_types import SpanTypeAttribute
|
|
54
|
+
from wrapt import wrap_function_wrapper
|
|
54
55
|
|
|
55
56
|
# Note: For detailed token and cost metrics, use patch_litellm() before importing DSPy.
|
|
56
57
|
# The DSPy callback focuses on execution flow and span hierarchy.
|
|
@@ -60,6 +61,8 @@ try:
|
|
|
60
61
|
except ImportError:
|
|
61
62
|
raise ImportError("DSPy is not installed. Please install it with: pip install dspy")
|
|
62
63
|
|
|
64
|
+
__all__ = ["BraintrustDSpyCallback", "patch_dspy"]
|
|
65
|
+
|
|
63
66
|
|
|
64
67
|
class BraintrustDSpyCallback(BaseCallback):
|
|
65
68
|
"""Callback handler that logs DSPy execution traces to Braintrust.
|
|
@@ -412,4 +415,52 @@ class BraintrustDSpyCallback(BaseCallback):
|
|
|
412
415
|
span.end()
|
|
413
416
|
|
|
414
417
|
|
|
415
|
-
|
|
418
|
+
def _configure_wrapper(wrapped, instance, args, kwargs):
|
|
419
|
+
"""Wrapper for dspy.configure that auto-adds BraintrustDSpyCallback."""
|
|
420
|
+
callbacks = kwargs.get("callbacks")
|
|
421
|
+
if callbacks is None:
|
|
422
|
+
callbacks = []
|
|
423
|
+
else:
|
|
424
|
+
callbacks = list(callbacks)
|
|
425
|
+
|
|
426
|
+
# Check if already has Braintrust callback
|
|
427
|
+
has_bt_callback = any(isinstance(cb, BraintrustDSpyCallback) for cb in callbacks)
|
|
428
|
+
if not has_bt_callback:
|
|
429
|
+
callbacks.append(BraintrustDSpyCallback())
|
|
430
|
+
|
|
431
|
+
kwargs["callbacks"] = callbacks
|
|
432
|
+
return wrapped(*args, **kwargs)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def patch_dspy() -> bool:
|
|
436
|
+
"""
|
|
437
|
+
Patch DSPy to automatically add Braintrust tracing callback.
|
|
438
|
+
|
|
439
|
+
After calling this, all calls to dspy.configure() will automatically
|
|
440
|
+
include the BraintrustDSpyCallback.
|
|
441
|
+
|
|
442
|
+
Returns:
|
|
443
|
+
True if DSPy was patched (or already patched), False if DSPy is not installed.
|
|
444
|
+
|
|
445
|
+
Example:
|
|
446
|
+
```python
|
|
447
|
+
import braintrust
|
|
448
|
+
braintrust.patch_dspy()
|
|
449
|
+
|
|
450
|
+
import dspy
|
|
451
|
+
lm = dspy.LM("openai/gpt-4o-mini")
|
|
452
|
+
dspy.configure(lm=lm) # BraintrustDSpyCallback auto-added!
|
|
453
|
+
```
|
|
454
|
+
"""
|
|
455
|
+
try:
|
|
456
|
+
import dspy
|
|
457
|
+
|
|
458
|
+
if getattr(dspy, "__braintrust_wrapped__", False):
|
|
459
|
+
return True # Already patched
|
|
460
|
+
|
|
461
|
+
wrap_function_wrapper("dspy", "configure", _configure_wrapper)
|
|
462
|
+
dspy.__braintrust_wrapped__ = True
|
|
463
|
+
return True
|
|
464
|
+
|
|
465
|
+
except ImportError:
|
|
466
|
+
return False
|
|
@@ -15,7 +15,13 @@ def setup_genai(
|
|
|
15
15
|
api_key: str | None = None,
|
|
16
16
|
project_id: str | None = None,
|
|
17
17
|
project_name: str | None = None,
|
|
18
|
-
):
|
|
18
|
+
) -> bool:
|
|
19
|
+
"""
|
|
20
|
+
Setup Braintrust integration with Google GenAI.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
True if setup was successful, False if google-genai is not installed.
|
|
24
|
+
"""
|
|
19
25
|
span = current_span()
|
|
20
26
|
if span == NOOP_SPAN:
|
|
21
27
|
init_logger(project=project_name, api_key=api_key, project_id=project_id)
|
|
@@ -27,11 +33,8 @@ def setup_genai(
|
|
|
27
33
|
genai.Client = wrap_client(genai.Client)
|
|
28
34
|
models.Models = wrap_models(models.Models)
|
|
29
35
|
models.AsyncModels = wrap_async_models(models.AsyncModels)
|
|
30
|
-
|
|
31
|
-
except ImportError
|
|
32
|
-
logger.error(
|
|
33
|
-
f"Failed to import Google ADK agents: {e}. Google ADK is not installed. Please install it with: pip install google-adk"
|
|
34
|
-
)
|
|
36
|
+
return True
|
|
37
|
+
except ImportError:
|
|
35
38
|
return False
|
|
36
39
|
|
|
37
40
|
|
braintrust/wrappers/litellm.py
CHANGED
|
@@ -631,13 +631,16 @@ def serialize_response_format(response_format: Any) -> Any:
|
|
|
631
631
|
return response_format
|
|
632
632
|
|
|
633
633
|
|
|
634
|
-
def patch_litellm():
|
|
634
|
+
def patch_litellm() -> bool:
|
|
635
635
|
"""
|
|
636
636
|
Patch LiteLLM to add Braintrust tracing.
|
|
637
637
|
|
|
638
638
|
This wraps litellm.completion and litellm.acompletion to automatically
|
|
639
639
|
create Braintrust spans with detailed token metrics, timing, and costs.
|
|
640
640
|
|
|
641
|
+
Returns:
|
|
642
|
+
True if LiteLLM was patched (or already patched), False if LiteLLM is not installed.
|
|
643
|
+
|
|
641
644
|
Example:
|
|
642
645
|
```python
|
|
643
646
|
import braintrust
|
|
@@ -657,52 +660,12 @@ def patch_litellm():
|
|
|
657
660
|
import litellm
|
|
658
661
|
|
|
659
662
|
if not hasattr(litellm, "_braintrust_wrapped"):
|
|
660
|
-
# Store originals for unpatch_litellm()
|
|
661
|
-
litellm._braintrust_original_completion = litellm.completion
|
|
662
|
-
litellm._braintrust_original_acompletion = litellm.acompletion
|
|
663
|
-
litellm._braintrust_original_responses = litellm.responses
|
|
664
|
-
litellm._braintrust_original_aresponses = litellm.aresponses
|
|
665
|
-
|
|
666
663
|
wrapped = wrap_litellm(litellm)
|
|
667
664
|
litellm.completion = wrapped.completion
|
|
668
665
|
litellm.acompletion = wrapped.acompletion
|
|
669
666
|
litellm.responses = wrapped.responses
|
|
670
667
|
litellm.aresponses = wrapped.aresponses
|
|
671
668
|
litellm._braintrust_wrapped = True
|
|
669
|
+
return True
|
|
672
670
|
except ImportError:
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
def unpatch_litellm():
|
|
677
|
-
"""
|
|
678
|
-
Restore LiteLLM to its original state, removing Braintrust tracing.
|
|
679
|
-
|
|
680
|
-
This undoes the patching done by patch_litellm(), restoring the original
|
|
681
|
-
completion, acompletion, responses, and aresponses functions.
|
|
682
|
-
|
|
683
|
-
Example:
|
|
684
|
-
```python
|
|
685
|
-
import braintrust
|
|
686
|
-
braintrust.patch_litellm()
|
|
687
|
-
|
|
688
|
-
# ... use litellm with tracing ...
|
|
689
|
-
|
|
690
|
-
braintrust.unpatch_litellm() # restore original behavior
|
|
691
|
-
```
|
|
692
|
-
"""
|
|
693
|
-
try:
|
|
694
|
-
import litellm
|
|
695
|
-
|
|
696
|
-
if hasattr(litellm, "_braintrust_wrapped"):
|
|
697
|
-
litellm.completion = litellm._braintrust_original_completion
|
|
698
|
-
litellm.acompletion = litellm._braintrust_original_acompletion
|
|
699
|
-
litellm.responses = litellm._braintrust_original_responses
|
|
700
|
-
litellm.aresponses = litellm._braintrust_original_aresponses
|
|
701
|
-
|
|
702
|
-
delattr(litellm, "_braintrust_wrapped")
|
|
703
|
-
delattr(litellm, "_braintrust_original_completion")
|
|
704
|
-
delattr(litellm, "_braintrust_original_acompletion")
|
|
705
|
-
delattr(litellm, "_braintrust_original_responses")
|
|
706
|
-
delattr(litellm, "_braintrust_original_aresponses")
|
|
707
|
-
except ImportError:
|
|
708
|
-
pass # litellm not available
|
|
671
|
+
return False
|
|
@@ -51,9 +51,8 @@ def setup_pydantic_ai(
|
|
|
51
51
|
wrap_model_classes()
|
|
52
52
|
|
|
53
53
|
return True
|
|
54
|
-
except ImportError
|
|
55
|
-
|
|
56
|
-
logger.error("Pydantic AI is not installed. Please install it with: pip install pydantic-ai-slim")
|
|
54
|
+
except ImportError:
|
|
55
|
+
# Not installed - this is expected when using auto_instrument()
|
|
57
56
|
return False
|
|
58
57
|
|
|
59
58
|
|
braintrust/wrappers/test_agno.py
CHANGED
|
@@ -8,6 +8,7 @@ import pytest
|
|
|
8
8
|
from braintrust import logger
|
|
9
9
|
from braintrust.test_helpers import init_test_logger
|
|
10
10
|
from braintrust.wrappers.agno import setup_agno
|
|
11
|
+
from braintrust.wrappers.test_utils import verify_autoinstrument_script
|
|
11
12
|
|
|
12
13
|
TEST_ORG_ID = "test-org-123"
|
|
13
14
|
PROJECT_NAME = "test-agno-app"
|
|
@@ -94,3 +95,11 @@ def test_agno_simple_agent_execution(memory_logger):
|
|
|
94
95
|
assert llm_span["metrics"]["prompt_tokens"] == 38
|
|
95
96
|
assert llm_span["metrics"]["completion_tokens"] == 4
|
|
96
97
|
assert llm_span["metrics"]["tokens"] == 42
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class TestAutoInstrumentAgno:
|
|
101
|
+
"""Tests for auto_instrument() with Agno."""
|
|
102
|
+
|
|
103
|
+
def test_auto_instrument_agno(self):
|
|
104
|
+
"""Test auto_instrument patches Agno and creates spans."""
|
|
105
|
+
verify_autoinstrument_script("test_auto_agno.py")
|