Skip to content

Commit ba72da4

Browse files
starfolkbotclaude
andcommitted
fix(litellm): use underlying provider for metadata, drop eager serialization
Align the LiteLLM integration with .agents/skills/sdk-integrations/SKILL.md. Audit against the SKILL surfaced four issues: - **`metadata.provider` was the framework name.** SKILL §Completion-style explicitly says LiteLLM must set `metadata.provider` to the underlying provider (openai / cohere / openrouter / ...), not "litellm". Fixed by routing every span through `_resolve_provider(model)` which calls `litellm.get_llm_provider(model)`; falls back to `"litellm"` only if the lookup fails. `@lru_cache(maxsize=256)` on the resolver so the hot path doesn't re-dispatch through litellm's routing table per call. - **Excess serialization.** Every non-streaming wrapper called `_try_to_dict(response)` on the full pydantic response then indexed into it — but `bt_json` already serializes at log time. Replaced with direct attribute reads via a shared `_get_field(obj, key, default)` helper that works on both dicts and SDK objects (openai / anthropic / cohere had all been open-coding this). Streaming paths left intact. - **Dead code.** `_is_litellm_patched` was only used by crewai until #587 moved crewai to the "wrapper never owns tokens" pattern; no remaining callers anywhere. Removed the helper and its two subprocess tests. - **Excessive comments.** Trimmed the huge `wrap_litellm` / `_is_litellm_patched` docstrings and the "wrapt wrapper for litellm.X" one-liner on every wrapper. Also lifted the rerank output cap (`results[:100]`) into `integrations/utils.RERANK_OUTPUT_MAX_RESULTS` and documented the "rerank exception to no-silent-caps" rule in the SKILL — cohere carries the same magic number and can adopt the shared constant in a follow-up. ## Test plan - [x] Updated existing VCR-backed tests to assert the underlying provider (`openai`, `cohere`, `openrouter`) instead of `"litellm"`. No cassette re-recording (HTTP requests unchanged; span-shape assertions only). - [x] Added positive `metadata["provider"] == "openrouter"` assertion to the existing OpenRouter cassette test as coverage for the new resolver. - [x] Dropped the two `_is_litellm_patched` subprocess tests alongside the helper. - [x] **No new mocks/fakes, no cassette re-recording.** - [x] `ruff check` + `ruff format --check` on touched files — clean. - [ ] Reviewer to run: `cd py && nox -s "test_litellm(latest)"` and `cd py && nox -s "test_litellm(1.74.0)"` (author's box has a broken Python 3.14 install). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent a978bb1 commit ba72da4

5 files changed

Lines changed: 146 additions & 323 deletions

File tree

‎.agents/skills/sdk-integrations/SKILL.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ The integration that directly owns the model/provider API response owns token ac
165165
- Do not over-serialize. Braintrust serializes at send/log time. Integration tracing only needs readable Python dicts/lists/scalars and materialized attachments.
166166
- Keep wrapper bodies thin: prepare traced input, open span, call provider, normalize result, log.
167167
- Do not wrap `span.log(...)` / `span.set_attributes(...)` in broad try/except. Braintrust span methods are boundary-safe.
168+
- **Rerank exception to "no silent caps":** rerank result lists can be huge and are noisy in the span UI; cap the `output` list at a fixed max (e.g. 100 entries) via a named constant. The cap is compact-span hygiene, not coverage bounding — the model already ranked everything.
168169

169170
### Span origin
170171

‎py/src/braintrust/integrations/litellm/__init__.py‎

Lines changed: 4 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,74 +1,18 @@
11
"""Braintrust LiteLLM integration."""
22

3-
import importlib
4-
53
from .integration import LiteLLMIntegration
6-
from .patchers import LiteLLMAcompletionPatcher, LiteLLMCompletionPatcher, wrap_litellm
4+
from .patchers import wrap_litellm
75

86

97
def patch_litellm() -> bool:
10-
"""Patch LiteLLM to add Braintrust tracing.
11-
12-
This wraps litellm.completion, litellm.acompletion, litellm.responses,
13-
litellm.aresponses, litellm.image_generation, litellm.aimage_generation,
14-
litellm.embedding, litellm.aembedding, litellm.moderation,
15-
litellm.speech, litellm.aspeech, litellm.transcription,
16-
litellm.atranscription, litellm.rerank, and litellm.arerank to automatically
17-
create Braintrust spans with detailed token metrics, timing, and costs.
18-
19-
Returns:
20-
True if LiteLLM was patched (or already patched), False if LiteLLM is not installed.
8+
"""Patch LiteLLM top-level callables to emit Braintrust spans.
219
22-
Example:
23-
```python
24-
import braintrust
25-
braintrust.integrations.litellm.patch_litellm()
26-
27-
import litellm
28-
from braintrust import init_logger
29-
30-
logger = init_logger(project="my-project")
31-
response = litellm.completion(
32-
model="gpt-4o-mini",
33-
messages=[{"role": "user", "content": "Hello"}]
34-
)
35-
```
10+
Returns ``True`` if LiteLLM was patched (or already patched), ``False``
11+
if LiteLLM is not installed.
3612
"""
3713
return LiteLLMIntegration.setup()
3814

3915

40-
def _is_litellm_patched() -> bool:
41-
"""Return ``True`` when the LiteLLM completion entry points are patched.
42-
43-
Internal helper used by other Braintrust integrations (e.g. CrewAI,
44-
which delegates its LLM calls to LiteLLM) to decide whether to emit
45-
token metrics on their own LLM spans. When LiteLLM is already
46-
patched, the Braintrust ``Completion`` span it produces is the leaf
47-
span and must own token accounting; non-leaf wrappers should emit
48-
timing-only metrics to avoid double-counting during trace-tree
49-
rollup.
50-
51-
Not part of the public API — cross-integration callers import this
52-
directly by its underscore-prefixed name.
53-
54-
Returns ``False`` if ``litellm`` is not importable or neither of the
55-
``completion`` / ``acompletion`` entry points has been patched.
56-
"""
57-
try:
58-
litellm = importlib.import_module("litellm")
59-
except ImportError:
60-
return False
61-
62-
completion = getattr(litellm, "completion", None)
63-
acompletion = getattr(litellm, "acompletion", None)
64-
return bool(
65-
LiteLLMCompletionPatcher.has_patch_marker(completion)
66-
or LiteLLMCompletionPatcher.has_patch_marker(litellm)
67-
or LiteLLMAcompletionPatcher.has_patch_marker(acompletion)
68-
or LiteLLMAcompletionPatcher.has_patch_marker(litellm)
69-
)
70-
71-
7216
__all__ = [
7317
"LiteLLMIntegration",
7418
"patch_litellm",

‎py/src/braintrust/integrations/litellm/patchers.py‎

Lines changed: 2 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -171,37 +171,9 @@ class LiteLLMArerankPatcher(FunctionWrapperPatcher):
171171

172172

173173
def wrap_litellm(litellm: Any) -> Any:
174-
"""Wrap a LiteLLM module to add Braintrust tracing.
174+
"""Wrap a LiteLLM module-like object in-place with Braintrust tracing.
175175
176-
Unlike :func:`patch_litellm`, which patches the globally-imported ``litellm``
177-
module, this function instruments a specific module object (or any object
178-
that exposes the same top-level callables such as ``completion``,
179-
``acompletion``, ``responses``, ``aresponses``, ``image_generation``,
180-
``text_completion``, ``atext_completion``, ``aimage_generation``,
181-
``embedding``, ``aembedding``, ``moderation``, ``amoderation``, ``speech``,
182-
``aspeech``, ``transcription``, ``atranscription``, ``rerank``, and
183-
``arerank``). Each patcher is applied idempotently — calling
184-
``wrap_litellm`` twice on the same object is safe.
185-
186-
Args:
187-
litellm: The ``litellm`` module or a module-like object that exposes
188-
the standard LiteLLM top-level functions.
189-
190-
Returns:
191-
The same *litellm* object, with tracing wrappers applied in-place.
192-
193-
Example::
194-
195-
import litellm
196-
from braintrust.integrations.litellm import wrap_litellm
197-
198-
wrap_litellm(litellm)
199-
200-
# All subsequent calls are automatically traced.
201-
response = litellm.completion(
202-
model="gpt-4o-mini",
203-
messages=[{"role": "user", "content": "Hello"}],
204-
)
176+
Idempotent; safe to call twice on the same object.
205177
"""
206178
for patcher in _ALL_LITELLM_PATCHERS:
207179
patcher.wrap_target(litellm)

‎py/src/braintrust/integrations/litellm/test_litellm.py‎

Lines changed: 25 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
from braintrust.integrations.litellm import patch_litellm
99
from braintrust.integrations.test_utils import (
1010
assert_metrics_are_valid,
11-
run_in_subprocess,
1211
verify_autoinstrument_script,
1312
)
1413
from braintrust.test_helpers import assert_dict_matches, init_test_logger
@@ -72,7 +71,7 @@ def test_litellm_completion_metrics(memory_logger) -> None:
7271
metrics = span["metrics"]
7372
assert_metrics_are_valid(metrics, start, end)
7473
assert span["metadata"]["model"] == TEST_MODEL
75-
assert span["metadata"]["provider"] == "litellm"
74+
assert span["metadata"]["provider"] == "openai"
7675
assert TEST_PROMPT in str(span["input"])
7776

7877

@@ -96,7 +95,7 @@ async def test_litellm_acompletion_metrics(memory_logger):
9695
metrics = span["metrics"]
9796
assert_metrics_are_valid(metrics, start, end)
9897
assert span["metadata"]["model"] == TEST_MODEL
99-
assert span["metadata"]["provider"] == "litellm"
98+
assert span["metadata"]["provider"] == "openai"
10099
assert TEST_PROMPT in str(span["input"])
101100

102101

@@ -119,7 +118,7 @@ def test_litellm_text_completion_metrics(memory_logger) -> None:
119118
metrics = span["metrics"]
120119
assert_metrics_are_valid(metrics, start, end)
121120
assert span["metadata"]["model"] == TEST_TEXT_MODEL
122-
assert span["metadata"]["provider"] == "litellm"
121+
assert span["metadata"]["provider"] == "openai"
123122
assert TEST_PROMPT in str(span["input"])
124123
assert "text" in span["output"][0]
125124

@@ -144,7 +143,7 @@ async def test_litellm_atext_completion_metrics(memory_logger):
144143
metrics = span["metrics"]
145144
assert_metrics_are_valid(metrics, start, end)
146145
assert span["metadata"]["model"] == TEST_TEXT_MODEL
147-
assert span["metadata"]["provider"] == "litellm"
146+
assert span["metadata"]["provider"] == "openai"
148147
assert TEST_PROMPT in str(span["input"])
149148
assert "text" in span["output"][0]
150149

@@ -186,7 +185,7 @@ def test_litellm_completion_streaming_sync(memory_logger):
186185
metrics = span["metrics"]
187186
assert_metrics_are_valid(metrics, start, end)
188187
assert span["metadata"]["model"] == TEST_MODEL
189-
assert span["metadata"]["provider"] == "litellm"
188+
assert span["metadata"]["provider"] == "openai"
190189
assert TEST_PROMPT in str(span["input"])
191190
assert "24" in str(span["output"]) or "twenty-four" in str(span["output"]).lower()
192191

@@ -220,7 +219,7 @@ async def test_litellm_acompletion_streaming_async(memory_logger):
220219
metrics = span["metrics"]
221220
assert_metrics_are_valid(metrics, start, end)
222221
assert span["metadata"]["model"] == TEST_MODEL
223-
assert span["metadata"]["provider"] == "litellm"
222+
assert span["metadata"]["provider"] == "openai"
224223
assert TEST_PROMPT in str(span["input"])
225224

226225

@@ -250,7 +249,7 @@ def test_litellm_responses_metrics(memory_logger):
250249
metrics = span["metrics"]
251250
assert_metrics_are_valid(metrics, start, end)
252251
assert span["metadata"]["model"] == TEST_MODEL
253-
assert span["metadata"]["provider"] == "litellm"
252+
assert span["metadata"]["provider"] == "openai"
254253
assert TEST_PROMPT in str(span["input"])
255254

256255

@@ -281,7 +280,7 @@ async def test_litellm_aresponses_metrics(memory_logger):
281280
metrics = span["metrics"]
282281
assert_metrics_are_valid(metrics, start, end)
283282
assert span["metadata"]["model"] == TEST_MODEL
284-
assert span["metadata"]["provider"] == "litellm"
283+
assert span["metadata"]["provider"] == "openai"
285284
assert TEST_PROMPT in str(span["input"])
286285

287286

@@ -303,7 +302,7 @@ def test_litellm_embeddings(memory_logger):
303302
span = spans[0]
304303
assert span
305304
assert span["metadata"]["model"] == "text-embedding-ada-002"
306-
assert span["metadata"]["provider"] == "litellm"
305+
assert span["metadata"]["provider"] == "openai"
307306
assert "This is a test" in str(span["input"])
308307

309308

@@ -323,7 +322,7 @@ async def test_litellm_aembedding(memory_logger):
323322
span = spans[0]
324323
assert span
325324
assert span["metadata"]["model"] == "text-embedding-ada-002"
326-
assert span["metadata"]["provider"] == "litellm"
325+
assert span["metadata"]["provider"] == "openai"
327326
assert "This is a test" in str(span["input"])
328327

329328

@@ -344,7 +343,7 @@ def test_litellm_moderation(memory_logger):
344343
span = spans[0]
345344
assert span
346345
assert span["metadata"]["model"] == "omni-moderation-latest"
347-
assert span["metadata"]["provider"] == "litellm"
346+
assert span["metadata"]["provider"] == "openai"
348347
assert "This is a test message" in str(span["input"])
349348

350349

@@ -362,7 +361,7 @@ async def test_litellm_amoderation(memory_logger):
362361
assert len(spans) == 1
363362
span = spans[0]
364363
assert span["metadata"]["model"] == "omni-moderation-latest"
365-
assert span["metadata"]["provider"] == "litellm"
364+
assert span["metadata"]["provider"] == "openai"
366365
assert "This is a test message" in str(span["input"])
367366

368367

@@ -386,7 +385,7 @@ def test_litellm_image_generation(memory_logger):
386385
assert len(spans) == 1
387386
span = spans[0]
388387
assert span["metadata"]["model"] == "gpt-image-1-mini"
389-
assert span["metadata"]["provider"] == "litellm"
388+
assert span["metadata"]["provider"] == "openai"
390389
assert span["input"] == prompt
391390
assert span["output"]["images_count"] == 1
392391
assert span["metrics"]["duration"] >= 0
@@ -413,7 +412,7 @@ async def test_litellm_aimage_generation(memory_logger):
413412
assert len(spans) == 1
414413
span = spans[0]
415414
assert span["metadata"]["model"] == "gpt-image-1-mini"
416-
assert span["metadata"]["provider"] == "litellm"
415+
assert span["metadata"]["provider"] == "openai"
417416
assert span["input"] == prompt
418417
assert span["output"]["images_count"] == 1
419418
assert span["metrics"]["duration"] >= 0
@@ -457,7 +456,7 @@ def test_litellm_transcription(memory_logger):
457456
assert len(spans) == 1
458457
span = spans[0]
459458
assert span["metadata"]["model"] == "whisper-1"
460-
assert span["metadata"]["provider"] == "litellm"
459+
assert span["metadata"]["provider"] == "openai"
461460
assert isinstance(span["input"]["file"], Attachment)
462461
assert span["input"]["file"].reference["filename"] == "test_audio.wav"
463462
assert span["input"]["file"].reference["content_type"] in ("audio/x-wav", "audio/wav") # OS-dependent
@@ -479,7 +478,7 @@ async def test_litellm_atranscription(memory_logger):
479478
assert len(spans) == 1
480479
span = spans[0]
481480
assert span["metadata"]["model"] == "whisper-1"
482-
assert span["metadata"]["provider"] == "litellm"
481+
assert span["metadata"]["provider"] == "openai"
483482
assert isinstance(span["input"]["file"], Attachment)
484483
assert span["input"]["file"].reference["filename"] == "test_audio.wav"
485484
assert span["input"]["file"].reference["content_type"] in ("audio/x-wav", "audio/wav") # OS-dependent
@@ -506,7 +505,7 @@ def test_litellm_speech(memory_logger):
506505
assert span["metadata"]["model"] == "tts-1"
507506
assert span["metadata"]["voice"] == "alloy"
508507
assert span["metadata"]["response_format"] == "mp3"
509-
assert span["metadata"]["provider"] == "litellm"
508+
assert span["metadata"]["provider"] == "openai"
510509
assert span["input"] == "Hello, this is a test."
511510
_assert_speech_output_attachment(span)
512511

@@ -532,7 +531,7 @@ async def test_litellm_aspeech(memory_logger):
532531
assert span["metadata"]["model"] == "tts-1"
533532
assert span["metadata"]["voice"] == "alloy"
534533
assert span["metadata"]["response_format"] == "mp3"
535-
assert span["metadata"]["provider"] == "litellm"
534+
assert span["metadata"]["provider"] == "openai"
536535
assert span["input"] == "Hello, this is a test."
537536
_assert_speech_output_attachment(span)
538537

@@ -636,7 +635,7 @@ async def test_litellm_async_parallel_requests(memory_logger):
636635
# Verify each span has proper data
637636
for i, span in enumerate(spans):
638637
assert span["metadata"]["model"] == TEST_MODEL
639-
assert span["metadata"]["provider"] == "litellm"
638+
assert span["metadata"]["provider"] == "openai"
640639
assert prompts[i] in str(span["input"])
641640
assert_metrics_are_valid(span["metrics"])
642641

@@ -687,7 +686,7 @@ def test_litellm_tool_calls(memory_logger):
687686
"span_attributes": {"type": "llm", "name": "Completion"},
688687
"metadata": {
689688
"model": TEST_MODEL,
690-
"provider": "litellm",
689+
"provider": "openai",
691690
"tools": lambda tools_list: (
692691
len(tools_list) == 1
693692
and any(tool.get("function", {}).get("name") == "get_weather" for tool in tools_list)
@@ -790,45 +789,6 @@ def test_patch_litellm_responses():
790789
verify_autoinstrument_script("test_patch_litellm_responses.py")
791790

792791

793-
def test_is_litellm_patched_internal_helper():
794-
"""Internal ``_is_litellm_patched()`` flips True after setup and honors manual wrap.
795-
796-
Run in a subprocess so that marker attributes set by other tests in this
797-
module do not leak into the assertion.
798-
"""
799-
result = run_in_subprocess(
800-
"""
801-
from braintrust.integrations.litellm import _is_litellm_patched, patch_litellm, wrap_litellm
802-
assert _is_litellm_patched() is False, 'expected unpatched at startup'
803-
assert patch_litellm() is True
804-
assert _is_litellm_patched() is True, 'expected True after patch_litellm()'
805-
# idempotent wrap on the already-patched module stays True.
806-
import litellm
807-
wrap_litellm(litellm)
808-
assert _is_litellm_patched() is True
809-
print("SUCCESS")
810-
"""
811-
)
812-
assert result.returncode == 0, result.stderr
813-
assert "SUCCESS" in result.stdout
814-
815-
816-
def test_is_litellm_patched_returns_false_without_litellm_installed():
817-
"""``_is_litellm_patched()`` must not raise when ``litellm`` is absent."""
818-
result = run_in_subprocess(
819-
"""
820-
import sys
821-
# Simulate a missing litellm install.
822-
sys.modules['litellm'] = None
823-
from braintrust.integrations.litellm import _is_litellm_patched
824-
assert _is_litellm_patched() is False
825-
print("SUCCESS")
826-
"""
827-
)
828-
assert result.returncode == 0, result.stderr
829-
assert "SUCCESS" in result.stdout
830-
831-
832792
def test_patch_litellm_aresponses():
833793
"""Test that patch_litellm() patches aresponses (subprocess to avoid global state pollution)."""
834794
verify_autoinstrument_script("test_patch_litellm_aresponses.py")
@@ -901,10 +861,10 @@ def test_litellm_openrouter_no_booleans_in_metrics(memory_logger):
901861
spans = memory_logger.pop()
902862
assert len(spans) == 1
903863
metrics = spans[0]["metrics"]
904-
metadata_keys = set(spans[0]["metadata"])
905-
assert "api_key" not in metadata_keys
864+
metadata = spans[0]["metadata"]
865+
assert "api_key" not in metadata
866+
assert metadata["provider"] == "openrouter"
906867

907-
# No boolean values should be in metrics
908868
for key, value in metrics.items():
909869
assert not isinstance(value, bool)
910870
assert "is_byok" not in metrics
@@ -934,7 +894,7 @@ def test_litellm_rerank(memory_logger):
934894
span = spans[0]
935895
assert span["span_attributes"]["name"] == "Rerank"
936896
assert span["span_attributes"]["type"] == "llm"
937-
assert span["metadata"]["provider"] == "litellm"
897+
assert span["metadata"]["provider"] == "cohere"
938898
assert span["metadata"]["model"] == RERANK_MODEL
939899
assert span["metadata"]["top_n"] == 2
940900
assert span["metadata"]["document_count"] == 3
@@ -979,7 +939,7 @@ async def test_litellm_arerank(memory_logger):
979939
assert len(spans) == 1
980940
span = spans[0]
981941
assert span["span_attributes"]["name"] == "Rerank"
982-
assert span["metadata"]["provider"] == "litellm"
942+
assert span["metadata"]["provider"] == "cohere"
983943
assert span["metadata"]["model"] == RERANK_MODEL
984944
assert span["metadata"]["top_n"] == 2
985945
assert span["metadata"]["document_count"] == 3

0 commit comments

Comments
 (0)